diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift index c8e2810..6f33a4a 100644 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift +++ b/Xcodes/AppleAPI/Sources/AppleAPI/Client.swift @@ -14,12 +14,22 @@ public class Client { return Current.network.dataTask(with: URLRequest.itcServiceKey) .map(\.data) .decode(type: ServiceKeyResponse.self, decoder: JSONDecoder()) - .flatMap { serviceKeyResponse -> AnyPublisher in + .flatMap { serviceKeyResponse -> AnyPublisher<(String, String), Swift.Error> in serviceKey = serviceKeyResponse.authServiceKey - return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password)) - .mapError { $0 as Swift.Error } + + // Fixes issue https://github.com/RobotsAndPencils/XcodesApp/issues/360 + // On 2023-02-23, Apple added a custom implementation of hashcash to their auth flow + // Without this addition, Apple ID's would get set to locked + return self.loadHashcash(accountName: accountName, serviceKey: serviceKey) + .map { return (serviceKey, $0)} .eraseToAnyPublisher() } + .flatMap { (serviceKey, hashcash) -> AnyPublisher in + + return Current.network.dataTask(with: URLRequest.signIn(serviceKey: serviceKey, accountName: accountName, password: password, hashcash: hashcash)) + .mapError { $0 as Swift.Error } + .eraseToAnyPublisher() + } .flatMap { result -> AnyPublisher in let (data, response) = result return Just(data) @@ -56,6 +66,44 @@ public class Client { .mapError { $0 as Swift.Error } .eraseToAnyPublisher() } + + func loadHashcash(accountName: String, serviceKey: String) -> AnyPublisher { + + Result { + try URLRequest.federate(account: accountName, serviceKey: serviceKey) + } + .publisher + .flatMap { request in + Current.network.dataTask(with: request) + .mapError { $0 as Error } + .tryMap { (data, response) throws -> (String) in + guard let urlResponse = response as? HTTPURLResponse else { + throw AuthenticationError.invalidSession + } + switch urlResponse.statusCode { + case 200..<300: + + let httpResponse = response as! HTTPURLResponse + guard let bitsString = httpResponse.allHeaderFields["X-Apple-HC-Bits"] as? String, let bits = UInt(bitsString) else { + throw AuthenticationError.invalidHashcash + } + guard let challenge = httpResponse.allHeaderFields["X-Apple-HC-Challenge"] as? String else { + throw AuthenticationError.invalidHashcash + } + guard let hashcash = Hashcash().mint(resource: challenge, bits: bits) else { + throw AuthenticationError.invalidHashcash + } + return (hashcash) + case 400, 401: + throw AuthenticationError.invalidHashcash + case let code: + throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse) + } + } + } + .eraseToAnyPublisher() + + } func handleTwoStepOrFactor(data: Data, response: URLResponse, serviceKey: String) -> AnyPublisher { let httpResponse = response as! HTTPURLResponse @@ -190,6 +238,7 @@ public enum AuthenticationState: Equatable { public enum AuthenticationError: Swift.Error, LocalizedError, Equatable { case invalidSession + case invalidHashcash case invalidUsernameOrPassword(username: String) case incorrectSecurityCode case unexpectedSignInResponse(statusCode: Int, message: String?) @@ -206,6 +255,8 @@ public enum AuthenticationError: Swift.Error, LocalizedError, Equatable { switch self { case .invalidSession: return "Your authentication session is invalid. Try signing in again." + case .invalidHashcash: + return "Could not create a hashcash for the session." case .invalidUsernameOrPassword: return "Invalid username and password combination." case .incorrectSecurityCode: diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift b/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift new file mode 100644 index 0000000..135f1ea --- /dev/null +++ b/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift @@ -0,0 +1,96 @@ +// +// Hashcash.swift +// +// +// Created by Matt Kiazyk on 2023-02-23. +// + +import Foundation +import CryptoKit +import CommonCrypto + +/* +# This App Store Connect hashcash spec was generously donated by... + # + # __ _ + # __ _ _ __ _ __ / _|(_) __ _ _ _ _ __ ___ ___ + # / _` || '_ \ | '_ \ | |_ | | / _` || | | || '__|/ _ \/ __| + # | (_| || |_) || |_) || _|| || (_| || |_| || | | __/\__ \ + # \__,_|| .__/ | .__/ |_| |_| \__, | \__,_||_| \___||___/ + # |_| |_| |___/ + # + # +*/ +public struct Hashcash { + /// A function to returned a minted hash, using a bit and resource string + /// + /** + X-APPLE-HC: 1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373 + ^ ^ ^ ^ ^ + | | | | +-- Counter + | | | +-- Resource + | | +-- Date YYMMDD[hhmm[ss]] + | +-- Bits (number of leading zeros) + +-- Version + + We can't use an off-the-shelf Hashcash because Apple's implementation is not quite the same as the spec/convention. + 1. The spec calls for a nonce called "Rand" to be inserted between the Ext and Counter. They don't do that at all. + 2. The Counter conventionally encoded as base-64 but Apple just uses the decimal number's string representation. + + Iterate from Counter=0 to Counter=N finding an N that makes the SHA1(X-APPLE-HC) lead with Bits leading zero bits + We get the "Resource" from the X-Apple-HC-Challenge header and Bits from X-Apple-HC-Bits + */ + /// - Parameters: + /// - resource: a string to be used for minting + /// - bits: grabbed from `X-Apple-HC-Bits` header + /// - date: Default uses Date() otherwise used for testing to check. + /// - Returns: A String hash to use in `X-Apple-HC` header on /signin + public func mint(resource: String, + bits: UInt = 10, + date: String? = nil) -> String? { + + let ver = "1" + + var ts: String + if let date = date { + ts = date + } else { + let formatter = DateFormatter() + formatter.dateFormat = "yyMMddHHmmss" + ts = formatter.string(from: Date()) + } + + let challenge = "\(ver):\(bits):\(ts):\(resource):" + + var counter = 0 + + while true { + guard let digest = ("\(challenge):\(counter)").sha1 else { + print("ERROR: Can't generate SHA1 digest") + return nil + } + + if digest == bits { + return "\(challenge):\(counter)" + } + counter += 1 + } + } +} + +extension String { + var sha1: Int? { + + let data = Data(self.utf8) + var digest = [UInt8](repeating: 0, count:Int(CC_SHA1_DIGEST_LENGTH)) + data.withUnsafeBytes { + _ = CC_SHA1($0.baseAddress, CC_LONG(data.count), &digest) + } + let bigEndianValue = digest.withUnsafeBufferPointer { + ($0.baseAddress!.withMemoryRebound(to: UInt32.self, capacity: 1) { $0 }) + }.pointee + let value = UInt32(bigEndian: bigEndianValue) + return value.leadingZeroBitCount + } +} + diff --git a/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift b/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift index 01b98fb..d052d63 100644 --- a/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift +++ b/Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift @@ -7,6 +7,7 @@ public extension URL { static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")! static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! } static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")! + static let federate = URL(string: "https://idmsa.apple.com/appleauth/auth/federate")! static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")! } @@ -15,7 +16,7 @@ public extension URLRequest { return URLRequest(url: .itcServiceKey) } - static func signIn(serviceKey: String, accountName: String, password: String) -> URLRequest { + static func signIn(serviceKey: String, accountName: String, password: String, hashcash: String) -> URLRequest { struct Body: Encodable { let accountName: String let password: String @@ -27,6 +28,7 @@ public extension URLRequest { request.allHTTPHeaderFields?["Content-Type"] = "application/json" request.allHTTPHeaderFields?["X-Requested-With"] = "XMLHttpRequest" request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey + request.allHTTPHeaderFields?["X-Apple-HC"] = hashcash request.allHTTPHeaderFields?["Accept"] = "application/json, text/javascript" request.httpMethod = "POST" request.httpBody = try! JSONEncoder().encode(Body(accountName: accountName, password: password)) @@ -117,4 +119,21 @@ public extension URLRequest { static var olympusSession: URLRequest { return URLRequest(url: .olympusSession) } + + static func federate(account: String, serviceKey: String) throws -> URLRequest { + struct FederateRequest: Encodable { + let accountName: String + let rememberMe: Bool + } + var request = URLRequest(url: .signIn) + request.allHTTPHeaderFields?["Accept"] = "application/json" + request.allHTTPHeaderFields?["Content-Type"] = "application/json" + request.httpMethod = "GET" + +// let encoder = JSONEncoder() +// encoder.outputFormatting = .withoutEscapingSlashes +// request.httpBody = try encoder.encode(FederateRequest(accountName: account, rememberMe: true)) + + return request + } } diff --git a/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift b/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift index 283dbc6..ecb93e7 100644 --- a/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift +++ b/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift @@ -2,14 +2,26 @@ import XCTest @testable import AppleAPI final class AppleAPITests: XCTestCase { - func testExample() { - // This is an example of a functional test case. - // Use XCTAssert and related functions to verify your tests produce the correct - // results. - XCTAssertEqual(AppleAPI().text, "Hello, World!") + + func testValidHashCashMint() { + let bits: UInt = 11 + let resource = "4d74fb15eb23f465f1f6fcbf534e5877" + let testDate = "20230223170600" + + let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate) + XCTAssertEqual(stamp, "1:11:20230223170600:4d74fb15eb23f465f1f6fcbf534e5877::6373") + } + func testValidHashCashMint2() { + let bits: UInt = 10 + let resource = "bb63edf88d2f9c39f23eb4d6f0281158" + let testDate = "20230224001754" + + let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate) + XCTAssertEqual(stamp, "1:10:20230224001754:bb63edf88d2f9c39f23eb4d6f0281158::866") } static var allTests = [ - ("testExample", testExample), + ("testValidHashCashMint", testValidHashCashMint), + ("testValidHashCashMint2", testValidHashCashMint2), ] }