diff --git a/Sources/Anisette/AnisetteDataManager.swift b/Sources/Anisette/AnisetteDataManager.swift index 8c64485..41a2955 100644 --- a/Sources/Anisette/AnisetteDataManager.swift +++ b/Sources/Anisette/AnisetteDataManager.swift @@ -433,8 +433,17 @@ public final class AnisetteDataManager: @unchecked Sendable { } } - let clientInfo = headers?.clientInfo ?? Constants.Anisette.defaultClientInfo - let client = try await getClient(for: resolvedMode, clientInfo: clientInfo) + let windowsClientInfo = " " + let akdUserAgent = "akd/1.0 CFNetwork/808.1.4" + var clientInfo = headers?.clientInfo ?? windowsClientInfo + if clientInfo.isEmpty || clientInfo.contains("MacBook") || clientInfo.contains(" AnisetteClient { + private func getClient(for mode: AnisetteMode, clientInfo: String, userAgent: String = "akd/1.0 CFNetwork/808.1.4") async throws -> AnisetteClient { switch mode { case .remote(let server): try FileManager.default.createDirectory(at: provisioningDir, withIntermediateDirectories: true) return try AnisetteClient( provisioningDir: provisioningDir, clientInfo: clientInfo, - userAgent: Constants.Anisette.defaultUserAgent, + userAgent: userAgent, lookupURL: Constants.Anisette.URLs.grandSlamLookup, requiredLibraries: Constants.Anisette.Libraries.requiredNames, provider: RemoteAnisetteDataProvider(serverURL: server) @@ -489,7 +498,7 @@ public final class AnisetteDataManager: @unchecked Sendable { return try AnisetteClient( provisioningDir: targetProvDir, clientInfo: clientInfo, - userAgent: Constants.Anisette.defaultUserAgent, + userAgent: userAgent, lookupURL: Constants.Anisette.URLs.grandSlamLookup, requiredLibraries: Constants.Anisette.Libraries.requiredNames, libraryDirectoryResolver: { libDir } @@ -648,7 +657,7 @@ public final class AnisetteDataManager: @unchecked Sendable { self.localProvider = try AnisetteClient( provisioningDir: prov, clientInfo: clientInfo, - userAgent: Constants.Anisette.defaultUserAgent, + userAgent: "akd/1.0 CFNetwork/808.1.4", lookupURL: Constants.Anisette.URLs.grandSlamLookup, requiredLibraries: Constants.Anisette.Libraries.requiredNames, libraryDirectoryResolver: { libDir } @@ -699,7 +708,7 @@ public final class AnisetteDataManager: @unchecked Sendable { let provider = try AnisetteClient( provisioningDir: prov, clientInfo: clientInfo, - userAgent: Constants.Anisette.defaultUserAgent, + userAgent: "akd/1.0 CFNetwork/808.1.4", lookupURL: Constants.Anisette.URLs.grandSlamLookup, requiredLibraries: Constants.Anisette.Libraries.requiredNames, libraryDirectoryResolver: { libDir } diff --git a/Sources/Constants.swift b/Sources/Constants.swift index 53d3070..02c336b 100644 --- a/Sources/Constants.swift +++ b/Sources/Constants.swift @@ -15,13 +15,13 @@ public enum Constants { public static let service = "iCloud" public static let headerVersion = "1.0.1" public static let authApp = "com.apple.gs.xcode.auth" - public static let userAgent = "AuthKit/1 (Macintosh; OS X 26.6) (com.apple.dt.Xcode/26.0)" + public static let userAgent = "akd/1.0 CFNetwork/808.1.4" // SideInstaller temporary Windows GSA fix } // https://idmsa.apple.com/appleauth/auth/devices public enum AppleAuth { public static let appIDKey = "ba2ec180e6ca6e6c6a542255453b24d6e6e5b2be0cc48bc1b0d8ad64cfe0228f" - public static let userAgent = "AuthKit/1 (Macintosh; OS X 26.6)" + public static let userAgent = "akd/1.0 CFNetwork/808.1.4" // SideInstaller temporary Windows GSA fix } // https://developerservices2.apple.com/services/ diff --git a/Sources/DeveloperPortal/Authentication.swift b/Sources/DeveloperPortal/Authentication.swift index 0b8e0f2..9991ba0 100644 --- a/Sources/DeveloperPortal/Authentication.swift +++ b/Sources/DeveloperPortal/Authentication.swift @@ -13,6 +13,12 @@ private enum PushedCodeState { @TaskLocal static var handled = false } +/// SideInstaller: after any 2FA succeeds, login again must not re-enter SMS/device flow +/// with a different machine identity (that yields GrandSlam HTTP 503). +private enum Completed2FAState { + @TaskLocal static var completed = false +} + public extension DeveloperPortal { func authenticate(appleID unsanitizedAppleID: String, @@ -197,11 +203,14 @@ public extension DeveloperPortal { ?? ((statusDictionary?["hsc"] as? Int) == 409 ? "pushedCode" : nil) verboseLog("[SideSign] Authentication status type: \(authType ?? "nil")") + let locked = Self.resolveClientIdentity(from: anisetteData) let twoFactorAuthContext = TwoFactorAuthContext( dsid: dsid, idmsToken: idmsToken, anisetteData: anisetteData, - xcodeVersion: xcodeVersion + xcodeVersion: xcodeVersion, + lockedClientInfo: locked.clientInfo, + lockedUserAgent: locked.userAgent ) switch authType { @@ -210,6 +219,15 @@ public extension DeveloperPortal { guard !pushedCode || !PushedCodeState.handled else { throw DeveloperPortalError.requiresTwoFactorAuthentication } + // Already finished 2FA this sign-in — Apple asking again usually means + // anisette identity changed (new adi). Do not open SMS again. + if Completed2FAState.completed { + debugLog("[SideSign] Apple requested 2FA again after completion (HTTP path) — refusing re-entry") + throw ServerError.badServerResponse( + reason: "Apple asked for 2FA again after the code was accepted (anisette identity mismatch). Reset adi.pb and sign in once.", + jsonPayload: authType ?? "" + ) + } let isTrustedDevice = pushedCode || authType == "trustedDeviceSecondaryAuth" || authType == "trustedDevice" try await handle2FARequest( isTrustedDevice: isTrustedDevice, @@ -219,17 +237,71 @@ public extension DeveloperPortal { verificationHandler: verificationHandler, codeAlreadySent: pushedCode ) - return try await PushedCodeState.$handled.withValue(PushedCodeState.handled || pushedCode) { - try await authenticate( - appleID: unsanitizedAppleID, - password: password, - anisetteData: anisetteData, - xcodeVersion: xcodeVersion, - machinePassword: machinePassword, - accountRepairHandler: accountRepairHandler, - verificationHandler: verificationHandler + // SideInstaller: after 2FA, login again with the SAME anisette machine + // (same adi / MD-M / device). Only clientTime is refreshed. + // Fetching a new adi mid-session is what produces GrandSlam HTTP 503 + // after the SMS code on every account. + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let lockedCI = twoFactorAuthContext.lockedClientInfo + let lockedUA = twoFactorAuthContext.lockedUserAgent + + try await Task.sleep(nanoseconds: 5_000_000_000) // SideInstaller: let Apple commit the SMS session + + var lastPost2FAError: Error? + let postDelays: [UInt64] = [ + 3_000_000_000, + 8_000_000_000, + 15_000_000_000, + 30_000_000_000, + 45_000_000_000, + ] + for attempt in 0.. 0 { + try await Task.sleep(nanoseconds: postDelays[attempt]) + } + + // SideInstaller get_anisette_data before login again (fresh OTP, same machine) + var post2FAAnisette = await Self.refreshOTPKeepingMachine( + anisetteData, + lockedCI: lockedCI, + lockedUA: lockedUA ) + post2FAAnisette.clientInfo = lockedCI + post2FAAnisette.userAgent = lockedUA + post2FAAnisette.clientTime = formatter.string(from: Date()) + + debugLog("[SideSign] Post-2FA re-login attempt \(attempt + 1)/\(postDelays.count) same-identity client=\(lockedCI.prefix(48))...") + + do { + return try await Completed2FAState.$completed.withValue(true) { + try await PushedCodeState.$handled.withValue(true) { + try await authenticate( + appleID: unsanitizedAppleID, + password: password, + anisetteData: post2FAAnisette, + xcodeVersion: xcodeVersion, + machinePassword: machinePassword, + accountRepairHandler: accountRepairHandler, + verificationHandler: verificationHandler + ) + } + } + } catch { + let msg = "\(error)".lowercased() + let is503 = msg.contains("503") || msg.contains("433") + debugLog("[SideSign] Post-2FA re-login attempt \(attempt + 1) failed: \(error)") + if is503, attempt < postDelays.count - 1 { + lastPost2FAError = error + continue + } + throw error + } } + throw lastPost2FAError ?? ServerError.badServerResponse( + reason: "Apple returned HTTP 503 after SMS. Same anisette identity was kept; wait and try once later.", + jsonPayload: "" + ) case "repair": let directRepairURL = completeResponseDictionary["repairUrl"] as? String @@ -317,29 +389,91 @@ public extension DeveloperPortal { request.httpMethod = "POST" request.httpBody = plistData - let headers: [String: String] = [ - "Content-Type": "text/x-xml-plist", - "X-MMe-Client-Info": anisetteData.clientInfo, - "Accept": "*/*", - "User-Agent": h.grandSlam.userAgent, - "Connection": "close" - ] - headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } + // SideInstaller client_info_to_use — one identity; stock/Android/Xcode → safe client. + // GSA answers stock Xcode-named clients and bad identities with HTTP 503. + let currentMac = " " + let currentMacUA = "AuthKit/1 (Macintosh; OS X 26.6) (com.apple.akd/1.0)" + + // SideInstaller 1.6.1: always report CURRENT_MAC through Mac ADI (fixes -80046) + let clientInfo = currentMac + let userAgent = currentMacUA + + func applyGrandSlamHeaders(_ req: inout URLRequest, clientInfo: String, userAgent: String) { + let allHeaders: [String: String] = [ + "Content-Type": "text/x-xml-plist", + "Accept": "text/x-xml-plist", + "User-Agent": userAgent, + "X-Mme-Client-Info": clientInfo, + "X-Xcode-Version": "14.2 (14C18)", + "X-Apple-App-Info": h.grandSlam.authApp.isEmpty ? "com.apple.gs.xcode.auth" : h.grandSlam.authApp, + "X-Apple-I-MD-M": anisetteData.machineID, + "X-Apple-I-MD": anisetteData.oneTimePassword, + "X-Apple-I-MD-LU": anisetteData.localUserID, + "X-Apple-I-MD-RINFO": anisetteData.routingInfo, + "X-Apple-I-SRL-NO": anisetteData.serialNumber, + "X-Apple-I-Client-Time": anisetteData.clientTime, + "X-Apple-Locale": anisetteData.locale, + "X-Apple-I-TimeZone": anisetteData.timeZone, + "X-MMe-Device-Id": anisetteData.deviceID, + "Connection": "close", // SideInstaller: GSA answers reused connections with 5xx + ] + allHeaders.forEach { req.setValue($1, forHTTPHeaderField: $0) } + } + applyGrandSlamHeaders(&request, clientInfo: clientInfo, userAgent: userAgent) if let allHeaders = request.allHTTPHeaderFields { verboseLog("[SideSign] sendAuthenticationRequest HTTP headers: \(prettyJSONString(from: sanitizeHeadersForLogging(allHeaders)))") } - let (data, response): (Data, URLResponse) - do { - (data, response) = try await session.data(for: request) - } catch { - debugLog("[SideSign] sendAuthenticationRequest network error: \(error)") - throw error + // SideInstaller: retry 429; also retry 503 with alternate safe clients (Mac then Windows) + let retryDelays: [UInt64] = [2_000_000_000, 5_000_000_000, 10_000_000_000, 15_000_000_000] + // Never switch to Windows mid-session on Mac ADI — that causes -80046 after the code + let clientFallbacks: [(String, String)] = [ + (currentMac, currentMacUA), + (currentMac, currentMacUA), + (currentMac, currentMacUA), + (currentMac, currentMacUA), + ] + var data = Data() + var response: URLResponse = URLResponse(url: Constants.URLs.grandSlamAuth, mimeType: nil, expectedContentLength: 0, textEncodingName: nil) + var statusCode = 0 + var attempt = 0 + while true { + let pair = clientFallbacks[min(attempt, clientFallbacks.count - 1)] + applyGrandSlamHeaders(&request, clientInfo: pair.0, userAgent: pair.1) + do { + (data, response) = try await dataWithFreshConnection(for: request) + } catch { + debugLog("[SideSign] sendAuthenticationRequest network error: \(error)") + throw error + } + let httpResponse = response as? HTTPURLResponse + statusCode = httpResponse?.statusCode ?? 0 + let retryable = statusCode == HTTPStatusCodes.tooManyRequests || statusCode == 503 || statusCode == 433 + if retryable, attempt < retryDelays.count { + let delay = retryDelays[attempt] + debugLog("[SideSign] GrandSlam HTTP \(statusCode) — retry \(attempt + 1) in \(delay / 1_000_000_000)s client=\(pair.0.prefix(40))...") + try await Task.sleep(nanoseconds: delay) + attempt += 1 + continue + } + break + } + + if statusCode == 503 || statusCode == 433 { + let rawStr = String(data: data, encoding: .utf8) ?? data.hexEncodedString() + debugLog("[SideSign] GrandSlam still HTTP \(statusCode) after retries: \(rawStr.prefix(200))") + throw ServerError.badServerResponse( + reason: "Apple returned HTTP \(statusCode) on sign-in (often rate-limit or bad anisette). Wait 15–30 minutes, Reset adi.pb, use Auto or a Mac server, try once.", + jsonPayload: rawStr + ) } - let httpResponse = response as? HTTPURLResponse - let statusCode = httpResponse?.statusCode ?? 0 + if statusCode != 0 && !(200...299).contains(statusCode) { + let rawStr = String(data: data, encoding: .utf8) ?? data.hexEncodedString() + debugLog("[SideSign] GrandSlam HTTP \(statusCode): \(rawStr.prefix(200))") + // continue to parse if body might be a plist with Status.ec; if empty/HTML, fail below + } guard !data.isEmpty else { debugLog("[SideSign] Auth endpoint returned 0 bytes (HTTP \(statusCode))") @@ -530,8 +664,34 @@ public extension DeveloperPortal { .filter { !$0.isEmpty } .joined(separator: ": ") debugLog("[SideSign] \(actionName) alert from Apple (HTTP \(statusCode)): \(alertMsg)") - throw DeveloperPortalError.invalid2FAResponse(cause: xmluiMessage ?? alertMsg) + // SideInstaller: never surface Apple's "Could not connect to iCloud" — map to phone refused + let lower = alertMsg.lowercased() + // Always map to PHONE_CODE_REFUSED — never pass Apple's iCloud wording to the UI + throw DeveloperPortalError.invalid2FAResponse(cause: "PHONE_CODE_REFUSED") + } + } + + /// True when Apple's 2FA page refused SMS/voice (often shown as "Could not connect to iCloud"). + private static func isPhoneOrICloudRefuse(_ text: String?) -> Bool { + guard let t = text?.lowercased(), !t.isEmpty else { return false } + return t.contains("icloud") + || t.contains("could not connect") + || t.contains("phone_code_refused") + || t == "phone_code_refused" + || t.contains("could not connect to icloud") + } + + /// Never show Apple's "Could not connect to iCloud" / internal markers to the user. + private static func sanitize2FAUserMessage(_ text: String?) -> String { + guard let text, !text.isEmpty else { return "Apple didn't confirm sending a code to your Apple devices. If one arrived, enter it; if not, try another way." } + if isPhoneOrICloudRefuse(text) || text == "PHONE_CODE_REFUSED" { + return "Apple won't send a code by text or call to this sign-in, so it was sent to your Apple devices." } + let lower = text.lowercased() + if lower.contains("icloud") { + return "Apple won't send a code by text or call to this sign-in, so it was sent to your Apple devices." + } + return text } private func parseXMLUIObfuscatedNumber(from data: Data) -> String? { @@ -559,6 +719,9 @@ public extension DeveloperPortal { let idmsToken: String let anisetteData: AnisetteData let xcodeVersion: String + /// SideInstaller: same device from start to finish — locked at first GrandSlam request + let lockedClientInfo: String + let lockedUserAgent: String } private struct TwoFactorAuthPhoneCodeResponse { @@ -596,12 +759,22 @@ public extension DeveloperPortal { phoneNumbers = parseTrustedPhoneNumbers(from: statusDictionary) } - let preferredMode: TwoFactorDeliveryMode = isTrustedDevice ? .trustedDevice : .sms + // SideInstaller 1.6.1: prefer devices. Apple's "Could not connect to iCloud" + // means phone refused — never surface that string; always keep a code field. + let phoneCodeRefusedMessage = "Apple won't send a code by text or call to this sign-in, so it was sent to your Apple devices." + let deviceCodeUnconfirmedMessage = "Apple didn't confirm sending a code to your Apple devices. If one arrived, enter it; if not, try another way." - var currentRequest: TwoFactorRequest = codeAlreadySent - ? .trustedDevice(error: nil) - : .selectDeliveryMethod(preferredMode: preferredMode, phoneNumbers: phoneNumbers) - var activeChannel: TwoFactorAuthChannel? = codeAlreadySent ? .trustedDevice : nil + // Always open on trusted-device prompt (codeAlreadySent or not). + // Soft-send device push; failure still allows entering a code that arrived. + do { + try await sendTrustedDevice2FACodeRequest(context: context) + } catch { + debugLog("[SideSign] Initial device push soft-fail: \(error)") + } + var currentRequest: TwoFactorRequest = .trustedDevice( + error: codeAlreadySent ? nil : deviceCodeUnconfirmedMessage + ) + var activeChannel: TwoFactorAuthChannel? = .trustedDevice while true { debugLog("[SideSign] Prompting user with 2FA request (\(currentRequest))...") @@ -609,44 +782,87 @@ public extension DeveloperPortal { switch response { case .requestTrustedDevice: - try await sendTrustedDevice2FACodeRequest(context: context) - activeChannel = .trustedDevice - currentRequest = .trustedDevice(error: nil) + do { + try await sendTrustedDevice2FACodeRequest(context: context) + activeChannel = .trustedDevice + currentRequest = .trustedDevice(error: nil) + } catch { + debugLog("[SideSign] Device push failed (\(error)); still accept code") + activeChannel = .trustedDevice + currentRequest = .trustedDevice(error: deviceCodeUnconfirmedMessage) + } case .requestSMS(let targetPhoneID): - let result = try await sendPhone2FACodeRequest(mode: "sms", phoneID: targetPhoneID, knownPhoneNumbers: phoneNumbers, context: context) - phoneNumbers = result.phoneNumbers - activeChannel = .sms(phoneID: result.phoneID) - currentRequest = .sms(phoneNumbers: phoneNumbers, activeID: result.phoneID, error: nil) + do { + let result = try await sendPhone2FACodeRequest( + mode: "sms", + phoneID: targetPhoneID, + knownPhoneNumbers: phoneNumbers, + context: context + ) + phoneNumbers = result.phoneNumbers.isEmpty ? phoneNumbers : result.phoneNumbers + activeChannel = .sms(phoneID: result.phoneID) + currentRequest = .sms(phoneNumbers: phoneNumbers, activeID: result.phoneID, error: nil) + } catch { + debugLog("[SideSign] SMS refused (\(error)); PHONE_CODE_REFUSED → devices") + do { try await sendTrustedDevice2FACodeRequest(context: context) } catch {} + activeChannel = .trustedDevice + currentRequest = .trustedDevice(error: phoneCodeRefusedMessage) + } case .requestVoice(let targetPhoneID): - let result = try await sendPhone2FACodeRequest(mode: "voice", phoneID: targetPhoneID, knownPhoneNumbers: phoneNumbers, context: context) - phoneNumbers = result.phoneNumbers - activeChannel = .voice(phoneID: result.phoneID) - currentRequest = .voice(phoneNumbers: phoneNumbers, activeID: result.phoneID, error: nil) + do { + let result = try await sendPhone2FACodeRequest( + mode: "voice", + phoneID: targetPhoneID, + knownPhoneNumbers: phoneNumbers, + context: context + ) + phoneNumbers = result.phoneNumbers.isEmpty ? phoneNumbers : result.phoneNumbers + activeChannel = .voice(phoneID: result.phoneID) + currentRequest = .voice(phoneNumbers: phoneNumbers, activeID: result.phoneID, error: nil) + } catch { + debugLog("[SideSign] Voice refused (\(error)); PHONE_CODE_REFUSED → devices") + do { try await sendTrustedDevice2FACodeRequest(context: context) } catch {} + activeChannel = .trustedDevice + currentRequest = .trustedDevice(error: phoneCodeRefusedMessage) + } case .verificationCode(let code): guard let channel = activeChannel else { - debugLog("[SideSign] Unexpected verification code returned before delivery method was selected.") - throw DeveloperPortalError.authenticationHandshakeFailed(cause: "Unexpected verification code returned before a delivery method was selected.") + activeChannel = .trustedDevice + currentRequest = .trustedDevice(error: deviceCodeUnconfirmedMessage) + continue } - let result: TwoFactorAuthValidationResult - switch channel { - case .trustedDevice: - result = try await validateTrustedDevice2FACode(code: code, context: context) - case .sms(let phoneID): - result = try await validatePhone2FACode(code: code, phoneID: phoneID, mode: "sms", context: context) - case .voice(let phoneID): - result = try await validatePhone2FACode(code: code, phoneID: phoneID, mode: "voice", context: context) + do { + switch channel { + case .trustedDevice: + result = try await validateTrustedDevice2FACode(code: code, context: context) + case .sms(let phoneID): + result = try await validatePhone2FACode(code: code, phoneID: phoneID, mode: "sms", context: context) + case .voice(let phoneID): + result = try await validatePhone2FACode(code: code, phoneID: phoneID, mode: "voice", context: context) + } + } catch { + // Never bubble "Could not connect to iCloud" + let msg = "\(error)" + debugLog("[SideSign] Code validation error (\(msg)); back to device prompt") + activeChannel = .trustedDevice + currentRequest = .trustedDevice(error: Self.isPhoneOrICloudRefuse(msg) ? phoneCodeRefusedMessage : deviceCodeUnconfirmedMessage) + continue } - switch result { case .success: debugLog("[SideSign] 2FA code verified successfully!") return case .retry(let message): - debugLog("[SideSign] 2FA verification failed, retrying: \(message)") + if Self.isPhoneOrICloudRefuse(message) || message.contains("PHONE_CODE_REFUSED") { + do { try await sendTrustedDevice2FACodeRequest(context: context) } catch {} + activeChannel = .trustedDevice + currentRequest = .trustedDevice(error: phoneCodeRefusedMessage) + continue + } switch channel { case .trustedDevice: currentRequest = .trustedDevice(error: message) @@ -668,22 +884,28 @@ public extension DeveloperPortal { debugLog("[SideSign] Requesting trusted device 2FA code...") verboseLog("[SideSign] sendTrustedDevice2FACodeRequest for dsid: \(context.dsid)") - var request = makeTwoFactorAuthRequest(url: Constants.URLs.trustedDevice, context: context) + var request = makeTwoFactorAuthRequest(url: Constants.URLs.trustedDevice, context: context, asMac: false) request.httpMethod = "GET" if let allHeaders = request.allHTTPHeaderFields { verboseLog("[SideSign] sendTrustedDevice2FACodeRequest HTTP headers: \(prettyJSONString(from: sanitizeHeadersForLogging(allHeaders)))") } - let (data, response) = try await session.data(for: request) - let httpResponse = response as? HTTPURLResponse - let statusCode = httpResponse?.safeStatusCode ?? 0 - try throwIfXMLUIErrorAlert(in: data, statusCode: statusCode, actionName: "sendTrustedDevice2FACodeRequest") - - guard statusCode == HTTPStatusCodes.ok else { - let rawStr = prettyJSONString(from: data) - debugLog("[SideSign] sendTrustedDevice2FACodeRequest failed (HTTP \(statusCode)): \(rawStr)") - throw ServerError.badServerResponse(reason: "Trusted device request failed (HTTP \(statusCode))", jsonPayload: rawStr) + do { + let (data, httpResponse) = try await performTwoFactorRequest(request, context: context, actionName: "sendTrustedDevice2FACodeRequest") + let statusCode = httpResponse.safeStatusCode + let rawPreview = String(data: data, encoding: .utf8) ?? "" + // SideInstaller DEVICE_CODE_UNCONFIRMED: never throw — always allow code entry + if statusCode != HTTPStatusCodes.ok + || rawPreview.lowercased().contains("icloud") + || rawPreview.lowercased().contains("could not connect") + { + debugLog("[SideSign] Trusted-device request soft-fail (HTTP \(statusCode)); prompting for code if one arrived") + } else { + debugLog("[SideSign] Trusted-device code request accepted") + } + } catch { + debugLog("[SideSign] Trusted-device request error soft-fail: \(error)") } } @@ -702,35 +924,68 @@ public extension DeveloperPortal { return "1" }() - let serverInfo: [String: any Sendable] = [ - "mode": requestedMode, - "phoneNumber.id": sanitizedPhoneID + // SideInstaller: PUT JSON to https://gsa.apple.com/auth/verify/phone + let phoneURL = URL(string: "https://gsa.apple.com/auth/verify/phone")! + var request = makeTwoFactorAuthRequest(url: phoneURL, context: context, asMac: false) + request.httpMethod = "PUT" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + // SideInstaller uses numeric phone id (u32); default 1 + let phoneIdValue: Int = Int(sanitizedPhoneID) ?? 1 + let jsonBody: [String: Any] = [ + "phoneNumber": ["id": phoneIdValue], + "mode": requestedMode ] - - var request = makeTwoFactorAuthRequest(url: Constants.URLs.phonePutURL(mode: requestedMode), context: context) - request.httpMethod = "POST" - request.httpBody = try PropertyListSerialization.data(fromPropertyList: [ - "serverInfo": serverInfo - ], format: .xml, options: 0) + request.httpBody = try JSONSerialization.data(withJSONObject: jsonBody) if let allHeaders = request.allHTTPHeaderFields { verboseLog("[SideSign] sendPhone2FACodeRequest HTTP headers: \(prettyJSONString(from: sanitizeHeadersForLogging(allHeaders)))") } - let (data, response) = try await session.data(for: request) - let httpResponse = response as? HTTPURLResponse - let statusCode = httpResponse?.safeStatusCode ?? 0 + let (data, httpResponse) = try await performTwoFactorRequest(request, context: context, actionName: "sendPhone2FACodeRequest") + let statusCode = httpResponse.safeStatusCode let rawStr = prettyJSONString(from: data) verboseLog("[SideSign] sendPhone2FACodeRequest raw response (HTTP \(statusCode)): \(rawStr)") - try throwIfXMLUIErrorAlert(in: data, statusCode: statusCode, actionName: "sendPhone2FACodeRequest") + // SideInstaller PHONE_CODE_REFUSED: Apple returns "Could not connect to iCloud" / bare 403 + // on Mac-ADI phone 2FA. Surface a consistent error so handle2FA falls back to devices. + let (xmluiTitle, xmluiMessage) = parseXMLUIAlertMessage(from: data) + let alertCombined = [xmluiTitle, xmluiMessage] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: ": ") + let alertLower = alertCombined.lowercased() + if statusCode == 403 || statusCode == 503 || statusCode == 423 || statusCode == 401 + || alertLower.contains("icloud") + || alertLower.contains("could not connect") + || alertLower.contains("forbidden") + { + let msg = xmluiMessage ?? alertCombined + if !msg.isEmpty { + debugLog("[SideSign] sendPhone2FACodeRequest Apple refused phone code (HTTP \(statusCode)): \(msg)") + } else { + debugLog("[SideSign] sendPhone2FACodeRequest bare HTTP 403 — phone code refused") + } + throw DeveloperPortalError.invalid2FAResponse(cause: "PHONE_CODE_REFUSED") + } + if xmluiTitle != nil || xmluiMessage != nil { + debugLog("[SideSign] sendPhone2FACodeRequest alert from Apple (HTTP \(statusCode)): \(alertCombined)") + throw DeveloperPortalError.invalid2FAResponse(cause: "PHONE_CODE_REFUSED") + } let responseDict = parsePlistOrJSON(data) let errorCode = responseDict?["ec"] as? Int ?? 0 let errorMsg = (responseDict?["em"] as? String) ?? ((responseDict?["Status"] as? [String: any Sendable])?["em"] as? String) + // serviceErrors / em may also say Could not connect to iCloud + let emLower = (errorMsg ?? "").lowercased() + if emLower.contains("icloud") || emLower.contains("could not connect") { + debugLog("[SideSign] sendPhone2FACodeRequest service error iCloud refuse: \(errorMsg ?? "")") + throw DeveloperPortalError.invalid2FAResponse(cause: "PHONE_CODE_REFUSED") + } + if errorCode == GrandSlamAuthErrorCodes.tooManyAttempts || errorCode == GrandSlamAuthErrorCodes.tooManyCodesRequested || errorCode == GrandSlamAuthErrorCodes.rateLimited @@ -742,13 +997,19 @@ public extension DeveloperPortal { } else if errorCode != 0 { let msg = errorMsg ?? "Failed to request verification code from Apple." debugLog("[SideSign] sendPhone2FACodeRequest error (\(errorCode), HTTP \(statusCode)): \(msg)") - throw ServerError.underlyingError(code: errorCode, message: msg) + throw ServerError.underlyingError(code: errorCode, message: Self.sanitize2FAUserMessage(msg)) } guard statusCode == HTTPStatusCodes.ok else { let reason = errorMsg ?? HTTPStatusCodes.localizedDescription(for: statusCode) debugLog("[SideSign] sendPhone2FACodeRequest failed (HTTP \(statusCode)): \(reason)") - throw ServerError.badServerResponse(reason: reason, jsonPayload: rawStr) + // 403 already handled above; any other non-OK still try to soft-signal iCloud-style refusals + // 423 Locked = phone method locked (same as SideInstaller PHONE_CODE_REFUSED) + if statusCode == 403 || statusCode == 401 || statusCode == 423 { + throw DeveloperPortalError.invalid2FAResponse(cause: "PHONE_CODE_REFUSED") + } + // Any non-OK phone response → treat as phone refused so UI falls back to devices + throw DeveloperPortalError.invalid2FAResponse(cause: "PHONE_CODE_REFUSED") } var parsedNumbers = parseTrustedPhoneNumbers(from: responseDict) @@ -797,7 +1058,7 @@ public extension DeveloperPortal { } private func validateTrustedDevice2FACode(code: String, context: TwoFactorAuthContext) async throws -> TwoFactorAuthValidationResult { - var verifyRequest = makeTwoFactorAuthRequest(url: Constants.URLs.grandSlamValidate, context: context) + var verifyRequest = makeTwoFactorAuthRequest(url: Constants.URLs.grandSlamValidate, context: context, asMac: false) verifyRequest.setValue(code, forHTTPHeaderField: "security-code") if let allHeaders = verifyRequest.allHTTPHeaderFields { @@ -805,9 +1066,8 @@ public extension DeveloperPortal { } debugLog("[SideSign] Verifying trusted device security code...") - let (verifyData, verifyResponse) = try await session.data(for: verifyRequest) - let verifyHttpResponse = verifyResponse as? HTTPURLResponse - let verifyStatusCode = verifyHttpResponse?.safeStatusCode ?? 0 + let (verifyData, verifyHttpResponse) = try await performTwoFactorRequest(verifyRequest, context: context, actionName: "validate2FACode") + let verifyStatusCode = verifyHttpResponse.safeStatusCode return try parseTwoFactorAuthVerifyResponse(data: verifyData, statusCode: verifyStatusCode, requirePeToken: false, httpResponse: verifyHttpResponse) } @@ -817,23 +1077,72 @@ public extension DeveloperPortal { mode: String, context: TwoFactorAuthContext) async throws -> TwoFactorAuthValidationResult { - var verifyRequest = makeTwoFactorAuthRequest(url: Constants.URLs.phoneSecurityCode, context: context) + // SideInstaller: get_anisette_data again before verify (OTP expires ~60s) + var ctx = context + let refreshed = await Self.refreshOTPKeepingMachine( + context.anisetteData, + lockedCI: context.lockedClientInfo, + lockedUA: context.lockedUserAgent + ) + ctx = TwoFactorAuthContext( + dsid: context.dsid, + idmsToken: context.idmsToken, + anisetteData: refreshed, + xcodeVersion: context.xcodeVersion, + lockedClientInfo: context.lockedClientInfo, + lockedUserAgent: context.lockedUserAgent + ) + + // SideInstaller verify_sms_2fa — exact JSON POST + let securityCodeURL = URL(string: "https://gsa.apple.com/auth/verify/phone/securitycode")! + var verifyRequest = makeTwoFactorAuthRequest(url: securityCodeURL, context: ctx, asMac: false) verifyRequest.httpMethod = "POST" - verifyRequest.httpBody = try PropertyListSerialization.data(fromPropertyList: [ - "securityCode.code": code, - "serverInfo": ["mode": mode, "phoneNumber.id": phoneID] - ], format: .xml, options: 0) + verifyRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + verifyRequest.setValue("application/json", forHTTPHeaderField: "Accept") + let phoneIdValue: Int = Int(phoneID) ?? 1 + let jsonBody: [String: Any] = [ + "securityCode": ["code": code], + "phoneNumber": ["id": phoneIdValue], + "mode": mode + ] + verifyRequest.httpBody = try JSONSerialization.data(withJSONObject: jsonBody) if let allHeaders = verifyRequest.allHTTPHeaderFields { verboseLog("[SideSign] validatePhone2FACode HTTP headers: \(prettyJSONString(from: sanitizeHeadersForLogging(allHeaders)))") } - debugLog("[SideSign] Verifying secondary security code...") - let (verifyData, verifyResponse) = try await session.data(for: verifyRequest) - let verifyHttpResponse = verifyResponse as? HTTPURLResponse - let verifyStatusCode = verifyHttpResponse?.safeStatusCode ?? 0 + debugLog("[SideSign] Verifying phone security code (SideInstaller JSON)...") + let (verifyData, verifyHttpResponse) = try await performTwoFactorRequest(verifyRequest, context: context, actionName: "validatePhone2FACode") + let verifyStatusCode = verifyHttpResponse.safeStatusCode + // SideInstaller: non-success → parse error; -21669 = wrong code → retry + if verifyStatusCode == 503 || verifyStatusCode == 433 { + var lastData = verifyData + var lastStatus = verifyStatusCode + var lastHttp = verifyHttpResponse + for (i, delay) in [3_000_000_000, 8_000_000_000, 15_000_000_000].enumerated() { + debugLog("[SideSign] Phone code submit HTTP \(lastStatus) — retry \(i + 1) after \(delay / 1_000_000_000)s") + try await Task.sleep(nanoseconds: delay) + let (retryData, retryHttp) = try await performTwoFactorRequest(verifyRequest, context: context, actionName: "validatePhone2FACode-retry") + lastData = retryData + lastStatus = retryHttp.safeStatusCode + lastHttp = retryHttp + if lastStatus != 503 && lastStatus != 433 { break } + } + return try parseTwoFactorAuthVerifyResponse( + data: lastData, + statusCode: lastStatus, + requirePeToken: false, + httpResponse: lastHttp + ) + } - return try parseTwoFactorAuthVerifyResponse(data: verifyData, statusCode: verifyStatusCode, requirePeToken: true, httpResponse: verifyHttpResponse) + // SideInstaller does not require X-Apple-PE-Token for phone verify success + return try parseTwoFactorAuthVerifyResponse( + data: verifyData, + statusCode: verifyStatusCode, + requirePeToken: false, + httpResponse: verifyHttpResponse + ) } private func parseTwoFactorAuthVerifyResponse(data: Data, @@ -865,13 +1174,25 @@ public extension DeveloperPortal { } else if errorCode != 0 { let msg = errorMsg ?? "2FA verification error" debugLog("[SideSign] 2FA verification error (\(errorCode), HTTP \(statusCode)): \(msg)") - throw ServerError.underlyingError(code: errorCode, message: msg) + if Self.isPhoneOrICloudRefuse(msg) { + return .retry(message: "Apple won't send a code by text or call to this sign-in, so it was sent to your Apple devices.") + } + throw ServerError.underlyingError(code: errorCode, message: Self.sanitize2FAUserMessage(msg)) } if xmluiTitle != nil || xmluiMessage != nil { let message = xmluiMessage ?? errorMsg ?? xmluiTitle ?? "Verification failed" debugLog("[SideSign] 2FA verification fatal alert from Apple (HTTP \(statusCode)): \(message)") - throw DeveloperPortalError.invalid2FAResponse(cause: message) + if Self.isPhoneOrICloudRefuse(message) { + // Treat as wrong channel / refused phone — ask user to use device code + return .retry(message: "Apple won't send a code by text or call to this sign-in, so it was sent to your Apple devices.") + } + throw DeveloperPortalError.invalid2FAResponse(cause: Self.sanitize2FAUserMessage(message)) + } + + if statusCode == 503 || statusCode == 433 { + debugLog("[SideSign] 2FA verification HTTP \(statusCode) — treating as retryable") + return .retry(message: "Apple temporarily refused the code (HTTP \(statusCode)). Wait a moment and try again, or use a code from your other Apple devices.") } guard statusCode == HTTPStatusCodes.ok else { @@ -893,38 +1214,248 @@ public extension DeveloperPortal { return .success } - private func makeTwoFactorAuthRequest(url: URL, context: TwoFactorAuthContext) -> URLRequest { + /// SideInstaller MAC_CLIENT_INFO: gsa.apple.com/auth 2FA pages often return a bare 403 + /// for the Windows client used on GrandSlam; retry those requests as a Mac. + + /// SideInstaller client_info_to_use — one identity for the whole sign-in. + + /// SideInstaller calls `get_anisette_data` again before "login again" after 2FA. + /// Fresh OTP (X-Apple-I-MD) avoids GrandSlam HTTP 503 on the post-code SRP. + private static func refreshAnisetteAfter2FA( + original: AnisetteData, + lockedClientInfo: String, + lockedUserAgent: String + ) async -> AnisetteData { + AnisetteDataManager.shared.clearCache() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + + // Prefer activeMode; else SideStore's menuAnisetteURL (UserDefaults) + let mode: AnisetteMode? = AnisetteDataManager.shared.activeMode ?? { + let urlStr = UserDefaults.standard.string(forKey: "menuAnisetteURL") + ?? UserDefaults.standard.string(forKey: "defaultServerURL") + ?? "" + guard let url = URL(string: urlStr), !urlStr.isEmpty else { return nil } + return .remote(server: url) + }() + + guard let mode else { + debugLog("[SideSign] Post-2FA anisette refresh: no server mode — keeping OTP, fresh clientTime only") + var copy = original + copy.clientInfo = lockedClientInfo + copy.userAgent = lockedUserAgent + copy.clientTime = formatter.string(from: Date()) + return copy + } + + let identifier = UUID(uuidString: original.deviceID) ?? UUID() + var requestHeaders = original.toRequestHeaders() + requestHeaders.clientInfo = lockedClientInfo + requestHeaders.userAgent = lockedUserAgent + requestHeaders.clientTime = formatter.string(from: Date()) + + do { + let (fresh, _) = try await AnisetteDataManager.shared.fetchAnisetteData( + mode: mode, + identifier: identifier, + existingAdiBlob: nil, + headers: requestHeaders + ) + var result = fresh + result.clientInfo = lockedClientInfo + result.userAgent = lockedUserAgent + result.clientTime = formatter.string(from: Date()) + if result.deviceID.isEmpty { result.deviceID = original.deviceID } + if result.machineID.isEmpty { result.machineID = original.machineID } + debugLog("[SideSign] Post-2FA anisette refresh OK (new OTP)") + return result + } catch { + debugLog("[SideSign] Post-2FA anisette refresh failed (\(error)); using prior headers + new clientTime") + var copy = original + copy.clientInfo = lockedClientInfo + copy.userAgent = lockedUserAgent + copy.clientTime = formatter.string(from: Date()) + return copy + } + } + + + + /// SideInstaller uses a fresh TCP connection per GSA request (pool_max_idle_per_host = 0). + /// Reused HTTP/2 connections are a known source of GrandSlam HTTP 503. + private func dataWithFreshConnection(for request: URLRequest) async throws -> (Data, URLResponse) { + let config = URLSessionConfiguration.ephemeral + config.httpShouldUsePipelining = false + config.httpMaximumConnectionsPerHost = 1 + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 90 + let ephemeral = URLSession(configuration: config) + defer { ephemeral.invalidateAndCancel() } + return try await ephemeral.data(for: request) + } + + + /// SideInstaller refreshes anisette before verify_sms and before login again (OTP ~60s). + /// Same machine (adi / MD-M) when possible; new X-Apple-I-MD only. + private static func refreshOTPKeepingMachine(_ original: AnisetteData, lockedCI: String, lockedUA: String) async -> AnisetteData { + AnisetteDataManager.shared.clearCache() + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + // SideInstaller CURRENT_MAC for Mac ADI — never finish token step as Windows (-80046) + let forcedCI = " " + let forcedUA = "AuthKit/1 (Macintosh; OS X 26.6) (com.apple.akd/1.0)" + _ = lockedCI; _ = lockedUA // callers pass locked; we force CURRENT_MAC for Mac ADI token step + + let mode: AnisetteMode? = AnisetteDataManager.shared.activeMode ?? { + let urlStr = UserDefaults.standard.string(forKey: "menuAnisetteURL") + ?? UserDefaults.standard.string(forKey: "defaultServerURL") + ?? "" + guard let url = URL(string: urlStr), !urlStr.isEmpty else { return nil } + return .remote(server: url) + }() + + guard let mode else { + var copy = original + copy.clientInfo = forcedCI + copy.userAgent = forcedUA + copy.clientTime = formatter.string(from: Date()) + return copy + } + + // SideStore stores adi.pb base64 for same-machine get_headers + let adiB64 = UserDefaults.standard.string(forKey: "SideSign.anisetteAdiBlob") + ?? UserDefaults.standard.string(forKey: "anisetteAdiBlob") + let adiBlob = adiB64.flatMap { Data(base64Encoded: $0) } + + let identifier = UUID(uuidString: original.deviceID) ?? UUID() + var requestHeaders = original.toRequestHeaders() + requestHeaders.clientInfo = forcedCI + requestHeaders.userAgent = forcedUA + requestHeaders.clientTime = formatter.string(from: Date()) + + do { + let (fresh, newBlob) = try await AnisetteDataManager.shared.fetchAnisetteData( + mode: mode, + identifier: identifier, + existingAdiBlob: adiBlob, + headers: requestHeaders + ) + if let newBlob { + UserDefaults.standard.set(newBlob.base64EncodedString(), forKey: "SideSign.anisetteAdiBlob") + } + // Use the full fresh headers from the same adi.pb (SideInstaller get_headers). + // Do NOT splice old machineID onto a new OTP — that mismatch is -80046 after SMS. + var result = fresh + result.clientInfo = forcedCI + result.userAgent = forcedUA + result.clientTime = formatter.string(from: Date()) + debugLog("[SideSign] Refreshed OTP client=\(forcedCI.prefix(40)) md-m=\(result.machineID.prefix(12))...") + return result + } catch { + debugLog("[SideSign] OTP refresh failed (\(error)); using prior OTP + new clientTime") + var copy = original + copy.clientInfo = forcedCI + copy.userAgent = forcedUA + copy.clientTime = formatter.string(from: Date()) + return copy + } + } + + private static func resolveClientIdentity(from anisetteData: AnisetteData) -> (clientInfo: String, userAgent: String) { + // SideInstaller remote_v3::client_info_to_use + 1.6.1 Mac ADI path + // -80046 after the code = Android ADI or non-current Mac through Mac servers + let currentMac = " " + let currentMacUA = "AuthKit/1 (Macintosh; OS X 26.6) (com.apple.akd/1.0)" + let windows = " " + let akdUA = "akd/1.0 CFNetwork/808.1.4" + let stockPrefixes = [ + " ", + " ", + ] + let advertised = anisetteData.clientInfo.trimmingCharacters(in: .whitespacesAndNewlines) + let isAndroid = advertised.contains("") || advertised.contains("Windows") { + return (windows, akdUA) + } + return (currentMac, currentMacUA) + } + + private static let macClientInfoFor2FA = " " + + private func makeTwoFactorAuthRequest(url: URL, context: TwoFactorAuthContext, asMac: Bool = false) -> URLRequest { + // SideInstaller build_2fa_headers + send_auth base_headers (minimal set). + // Extra headers (Client-Time, Locale, SRL-NO, buddyml Accept, etc.) correlate with HTTP 503. let identityToken = "\(context.dsid):\(context.idmsToken)" let encodedIdentityToken = Data(identityToken.utf8).base64EncodedString() + let a = context.anisetteData + let clientInfo = asMac ? Self.macClientInfoFor2FA : context.lockedClientInfo + let ua = asMac ? "AuthKit/1 (Macintosh; OS X 27.0) (com.apple.akd/1.0)" : context.lockedUserAgent var request = URLRequest(url: url) - let a = context.anisetteData - let h = customHeaders + // Defaults match SideInstaller non-JSON auth pages; callers override for JSON phone. let headers: [String: String] = [ - "Accept": "application/x-buddyml", - "Accept-Language": "en-us", - "Content-Type": "application/x-plist", - "User-Agent": h.developerServices.userAgent, - "X-Apple-App-Info": h.grandSlam.authApp, - "X-Xcode-Version": context.xcodeVersion, + "Content-Type": "text/x-xml-plist", + "Accept": "text/x-xml-plist", + "User-Agent": ua, + "X-Mme-Client-Info": clientInfo, + "X-Xcode-Version": "14.2 (14C18)", + "X-Apple-App-Info": "com.apple.gs.xcode.auth", "X-Apple-Identity-Token": encodedIdentityToken, + // SideInstaller get_headers: only these three anisette fields on 2FA pages + "X-Mme-Device-Id": a.deviceID, "X-Apple-I-MD": a.oneTimePassword, "X-Apple-I-MD-M": a.machineID, - "X-Mme-Device-Id": a.deviceID, - "X-MMe-Client-Info": a.clientInfo, - "X-Apple-I-MD-LU": a.localUserID, "X-Apple-I-MD-RINFO": a.routingInfo, - "X-Apple-I-SRL-NO": a.serialNumber, - "X-Apple-I-Client-Time": a.clientTime, - "X-Apple-Locale": a.locale, - "X-Apple-I-TimeZone": a.timeZone, - "Connection": "close" + "Connection": "close", ] headers.forEach { request.setValue($1, forHTTPHeaderField: $0) } return request } - private func parsePlistOrJSON(_ data: Data) -> [String: any Sendable]? { + private func performTwoFactorRequest(_ request: URLRequest, context: TwoFactorAuthContext, actionName: String) async throws -> (Data, HTTPURLResponse) { + var current = request + var triedMac = false + while true { + let (data, response) = try await dataWithFreshConnection(for: current) + let http = response as? HTTPURLResponse + let status = http?.safeStatusCode ?? 0 + let body = String(data: data, encoding: .utf8) ?? "" + + // SideInstaller refuses_client: only bare 403 (no serviceErrors), not 503 + let hasServiceErrors = body.contains("serviceErrors") || body.contains("ec") + let bare403 = status == 403 && !hasServiceErrors + let shouldRetryAsMac = !triedMac && bare403 + if shouldRetryAsMac { + debugLog("[SideSign] \(actionName) bare HTTP 403 — retrying as MAC_CLIENT_INFO (SideInstaller send_auth)") + guard let url = current.url else { + throw ServerError.badServerResponse(reason: "\(actionName) missing URL", jsonPayload: body) + } + var macRequest = makeTwoFactorAuthRequest(url: url, context: context, asMac: true) + macRequest.httpMethod = current.httpMethod + macRequest.httpBody = current.httpBody + current = macRequest + triedMac = true + continue + } + guard let http else { + throw ServerError.badServerResponse(reason: "\(actionName) non-HTTP response", jsonPayload: body) + } + return (data, http) + } + } + +private func parsePlistOrJSON(_ data: Data) -> [String: any Sendable]? { (try? PropertyListSerialization.propertyList(from: data, format: nil)) as? [String: any Sendable] ?? (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: any Sendable] } diff --git a/Sources/DeveloperPortal/DeveloperPortalAPI.swift b/Sources/DeveloperPortal/DeveloperPortalAPI.swift index dfea7cc..bd6ac7f 100644 --- a/Sources/DeveloperPortal/DeveloperPortalAPI.swift +++ b/Sources/DeveloperPortal/DeveloperPortalAPI.swift @@ -255,8 +255,21 @@ public final class DeveloperPortal: DeveloperPortalAPI, Sendable { let session: URLSession - public init(session: URLSession = .shared, customHeaders: SideSignHeaders = SideSignHeaders()) { - self.session = session + /// SideInstaller uses pool_max_idle_per_host(0) so GSA never reuses connections + /// (reused connections often get HTTP 5xx / empty bodies). + private static func makeGSASession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.httpShouldUsePipelining = false + config.httpMaximumConnectionsPerHost = 1 + config.requestCachePolicy = .reloadIgnoringLocalCacheData + config.urlCache = nil + config.timeoutIntervalForRequest = 60 + config.timeoutIntervalForResource = 120 + return URLSession(configuration: config) + } + + public init(session: URLSession? = nil, customHeaders: SideSignHeaders = SideSignHeaders()) { + self.session = session ?? Self.makeGSASession() self.cachedCustomHeaders = customHeaders } @@ -327,7 +340,7 @@ public final class DeveloperPortal: DeveloperPortalAPI, Sendable { "X-Apple-I-MD-LU": a.localUserID, "X-Apple-I-MD-RINFO": a.routingInfo, "X-Mme-Device-Id": a.deviceID, - "X-MMe-Client-Info": a.clientInfo, + "X-Mme-Client-Info": a.clientInfo, "X-Apple-I-Client-Time": a.clientTime, "X-Apple-Locale": a.locale, "X-Apple-I-Locale": a.locale, @@ -438,7 +451,7 @@ public final class DeveloperPortal: DeveloperPortalAPI, Sendable { headers["X-Apple-I-MD-LU"] = a.localUserID headers["X-Apple-I-MD-RINFO"] = a.routingInfo headers["X-Mme-Device-Id"] = a.deviceID - headers["X-MMe-Client-Info"] = a.clientInfo + headers["X-Mme-Client-Info"] = a.clientInfo headers["X-Apple-I-Client-Time"] = a.clientTime headers["X-Apple-Locale"] = a.locale headers["X-Apple-I-Locale"] = a.locale diff --git a/Sources/Models/Errors.swift b/Sources/Models/Errors.swift index cca02c7..7db98be 100644 --- a/Sources/Models/Errors.swift +++ b/Sources/Models/Errors.swift @@ -65,7 +65,17 @@ public enum DeveloperPortalError: Error, LocalizedError, Sendable { case .invalidAnisetteData(let cause): return "Invalid anisette data: \(cause)" case .tooManyCertificates(let cause): return "Maximum number of certificates reached: \(cause)" case .accountRepairRequired(_, let message): return message.isEmpty ? Constants.defaultAccountRepairMessage : message - case .invalid2FAResponse(let cause): return cause ?? "Invalid two-factor authentication response." + case .invalid2FAResponse(let cause): + // Never show Apple's "Could not connect to iCloud" (SideInstaller PHONE_CODE_REFUSED) + if let cause { + let lower = cause.lowercased() + if lower.contains("icloud") || lower.contains("could not connect") || cause == "PHONE_CODE_REFUSED" { + return "Apple won't send a code by text or call to this sign-in, so it was sent to your Apple devices." + } + return cause + } + return "Invalid two-factor authentication response." + } } }