From 30353900b0bda60bd6926c45ab91a714bff26844 Mon Sep 17 00:00:00 2001 From: Calvin-Zikakis <24500770+Calvin-Zikakis@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:04:14 -0700 Subject: [PATCH 1/2] Report server errors instead of a plist parse failure during GSA auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendAuthenticationRequest() handed the GSA response body straight to PropertyListSerialization without checking that the server actually sent a plist. When gsa.apple.com returns an HTML error page — it serves one from its own edge, e.g. a 401 "Authorization Required" page with Server: Apple — the parse failure was forwarded verbatim, surfacing as NSCocoaErrorDomain 3840 "Encountered unknown tag html on line 1". AltServer wraps that as "could not sign in with your Apple ID", which reads as a credential or anisette problem rather than a server error. ALTAppleAPI.m already maps a failed parse to NSURLErrorBadServerResponse for the non-auth endpoints; the auth path was the outlier. Add a propertyListResponse() helper used by the GsService2 handler and the trusted-device 2FA verify handler, which had the same blind parse. It parses first and only enriches on failure, rather than gating on the status code: a status gate cannot catch HTML returned with a 200, and GSA mirrors its status into the body as Status.hsc alongside ec/em, so bailing out before reading the body risks masking real error codes such as -20101. Parsing first also means no currently-working request changes behaviour. On failure the error becomes NSURLErrorBadServerResponse carrying the original parse error as NSUnderlyingErrorKey, plus the status, Content-Type and a 256-byte body snippet in NSDebugDescriptionErrorKey. Reported in altstoreio/AltStore#1776, #1699 and #1747. --- .../Sources/ALTAppleAPI+Authentication.swift | 97 +++++++++++++++++-- 1 file changed, 88 insertions(+), 9 deletions(-) diff --git a/AltSign/Sources/ALTAppleAPI+Authentication.swift b/AltSign/Sources/ALTAppleAPI+Authentication.swift index 2d3e50c2..99a2c5e9 100644 --- a/AltSign/Sources/ALTAppleAPI+Authentication.swift +++ b/AltSign/Sources/ALTAppleAPI+Authentication.swift @@ -257,11 +257,7 @@ private extension ALTAppleAPI let verifyCodeTask = self.session.dataTask(with: request) { (data, response, error) in do { - guard let data = data else { throw error ?? ALTAppleAPIError.unknown() } - - guard let responseDictionary = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] else { - throw URLError(.badServerResponse) - } + let responseDictionary = try self.propertyListResponse(data: data, response: response, error: error) let errorCode = responseDictionary["ec"] as? Int ?? 0 guard errorCode != 0 else { return completionHandler(.success(())) } @@ -421,6 +417,90 @@ private extension ALTAppleAPI private extension ALTAppleAPI { + /// Parses a property list response from Apple, verifying the server actually sent one. + /// + /// Apple's GSA edge answers rejected requests with an HTML error page rather than a plist + /// (`Server: Apple`, `Content-Type: text/html`, body beginning ``). Handing that + /// straight to PropertyListSerialization surfaces an opaque NSCocoaErrorDomain 3840 + /// "Encountered unknown tag html on line 1", which hides both the HTTP status and the fact + /// that this is a server-side failure rather than incorrect credentials. Report + /// NSURLErrorBadServerResponse instead, attaching the status, Content-Type, and a snippet of + /// the body so bug reports are diagnosable. + func propertyListResponse(data: Data?, response: URLResponse?, error: Error?) throws -> [String: Any] + { + if let error = error { throw error } + guard let data = data else { throw ALTAppleAPIError.unknown() } + + let propertyList: Any + + do + { + propertyList = try PropertyListSerialization.propertyList(from: data, format: nil) + } + catch + { + throw self.badServerResponseError(data: data, response: response, underlyingError: error) + } + + // Parsed, but it isn't a dictionary, so there is no underlying parse error to report. + guard let responseDictionary = propertyList as? [String: Any] else { + throw self.badServerResponseError(data: data, response: response, underlyingError: nil) + } + + return responseDictionary + } + + func badServerResponseError(data: Data?, response: URLResponse?, underlyingError: Error?) -> Error + { + var debugComponents = [String]() + + if let httpResponse = response as? HTTPURLResponse + { + debugComponents.append("HTTP \(httpResponse.statusCode)") + + if let contentType = httpResponse.value(forHTTPHeaderField: "Content-Type") + { + debugComponents.append("Content-Type: \(contentType)") + } + } + + if let data = data + { + debugComponents.append("\(data.count) bytes") + + // Responses we fail to parse are server error pages, not user data, but cap the + // snippet and strip newlines so it stays readable in logs and bug reports. + let snippet = String(decoding: data.prefix(256), as: UTF8.self) + .replacingOccurrences(of: "\n", with: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + if !snippet.isEmpty + { + debugComponents.append("Body: \(snippet)") + } + } + + var userInfo = [String: Any]() + userInfo[NSDebugDescriptionErrorKey] = debugComponents.joined(separator: ", ") + + // NSURLErrorBadServerResponse has no localized description of its own, so provide one; + // otherwise callers surface "NSURLErrorDomain error -1011" to the user. + if let httpResponse = response as? HTTPURLResponse + { + userInfo[NSLocalizedDescriptionKey] = String(format: NSLocalizedString("Apple's servers returned an unexpected response (HTTP %ld).", comment: ""), httpResponse.statusCode) + } + else + { + userInfo[NSLocalizedDescriptionKey] = NSLocalizedString("Apple's servers returned an unexpected response.", comment: "") + } + + if let underlyingError = underlyingError + { + userInfo[NSUnderlyingErrorKey] = underlyingError + } + + return NSError(domain: NSURLErrorDomain, code: NSURLErrorBadServerResponse, userInfo: userInfo) + } + func sendAuthenticationRequest(parameters requestParameters: [String: Any], anisetteData: ALTAnisetteData, completionHandler: @escaping (Result<[String: Any], Error>) -> Void) { do @@ -449,12 +529,11 @@ private extension ALTAppleAPI let dataTask = self.session.dataTask(with: request) { (data, response, error) in do { - guard let data = data else { throw error ?? ALTAppleAPIError.unknown() } + let responseDictionary = try self.propertyListResponse(data: data, response: response, error: error) - guard let responseDictionary = try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], - let dictionary = responseDictionary["Response"] as? [String: Any], + guard let dictionary = responseDictionary["Response"] as? [String: Any], let status = dictionary["Status"] as? [String: Any] - else { throw URLError(.badServerResponse) } + else { throw self.badServerResponseError(data: data, response: response, underlyingError: nil) } let errorCode = status["ec"] as? Int ?? 0 guard errorCode != 0 else { return completionHandler(.success(dictionary)) } From a91fb9e6e7f2c83d131bef41d51b96545edf55b7 Mon Sep 17 00:00:00 2001 From: Calvin-Zikakis <24500770+Calvin-Zikakis@users.noreply.github.com> Date: Thu, 3 Sep 2026 22:07:29 -0700 Subject: [PATCH 2/2] Retry GSA auth requests on 5xx over a fresh connection Apple's GSA edge keeps a keep-alive connection pinned to a backend node. When that node starts failing, every subsequent request on the same connection returns 5xx and never recovers. Measured on an affected Mac, six requests over one reused connection: 200 200 503 503 503 503 200 503 503 503 503 503 200 200 200 200 503 503 while six requests each on a fresh connection fail independently at roughly half: 503 200 503 503 503 200 200 503 503 200 200 503 ALTAppleAPI keeps a single session for every request, so authenticate() sends init, complete and apptokens down one connection. init and complete go through, the connection sours, and apptokens gets an HTML 503 that surfaces as NSCocoaErrorDomain 3840. That matches the reports exactly, and explains why the failure looked specific to apptokens when it is really just whichever request lands after the connection goes bad. Retry 5xx up to five times with exponential backoff, giving each attempt its own session so it opens a new connection. Retrying on the shared session is useless here, since every attempt inherits the same dead node. Verified on a machine that had never once completed sign in: all three calls returned 200 and apptokens returned its token on the first attempt. Reported in altstoreio/AltStore#1776, #1699 and #1747. --- .../Sources/ALTAppleAPI+Authentication.swift | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/AltSign/Sources/ALTAppleAPI+Authentication.swift b/AltSign/Sources/ALTAppleAPI+Authentication.swift index 99a2c5e9..c94c4a18 100644 --- a/AltSign/Sources/ALTAppleAPI+Authentication.swift +++ b/AltSign/Sources/ALTAppleAPI+Authentication.swift @@ -415,6 +415,8 @@ private extension ALTAppleAPI } } +private let ALTMaximumGSARetries = 5 + private extension ALTAppleAPI { /// Parses a property list response from Apple, verifying the server actually sent one. @@ -526,7 +528,30 @@ private extension ALTAppleAPI request.httpBody = bodyData httpHeaders.forEach { request.addValue($0.value, forHTTPHeaderField: $0.key) } - let dataTask = self.session.dataTask(with: request) { (data, response, error) in + var attempt = 0 + + func send() + { + // Apple's GSA edge keeps a connection pinned to a backend node, and once that node + // starts failing every subsequent request on the same connection returns 5xx and never + // recovers. Retrying over the shared session therefore just repeats the same failure, + // so give each attempt its own session to force a new connection. + let attemptSession = URLSession(configuration: .ephemeral) + + let dataTask = attemptSession.dataTask(with: request) { (data, response, error) in + attemptSession.finishTasksAndInvalidate() + + // A 5xx means the request was never processed, so retrying is safe. + if let httpResponse = response as? HTTPURLResponse, (500...599).contains(httpResponse.statusCode), attempt < ALTMaximumGSARetries - 1 + { + attempt += 1 + + let delay = min(pow(2.0, Double(attempt - 1)), 8.0) + DispatchQueue.global().asyncAfter(deadline: .now() + delay) { send() } + + return + } + do { let responseDictionary = try self.propertyListResponse(data: data, response: response, error: error) @@ -556,6 +581,9 @@ private extension ALTAppleAPI } dataTask.resume() + } + + send() } catch {