From 76bb3fbae0f945673198818116b3d61b8349999a Mon Sep 17 00:00:00 2001 From: Matt Kiazyk Date: Mon, 27 Feb 2023 12:11:12 -0600 Subject: [PATCH] Adds hashcash implementation --- Xcodes/AppleAPI/Sources/AppleAPI/Client.swift | 57 ++++++++- .../AppleAPI/Sources/AppleAPI/Hashcash.swift | 121 +++++++----------- Xcodes/AppleAPI/Sources/AppleAPI/Stamp.swift | 101 --------------- .../Sources/AppleAPI/URLRequest+Apple.swift | 21 ++- .../Tests/AppleAPITests/AppleAPITests.swift | 17 ++- 5 files changed, 129 insertions(+), 188 deletions(-) delete mode 100644 Xcodes/AppleAPI/Sources/AppleAPI/Stamp.swift 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 index 42d8b39..135f1ea 100644 --- a/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift +++ b/Xcodes/AppleAPI/Sources/AppleAPI/Hashcash.swift @@ -9,13 +9,44 @@ 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 = 20, - ext: String = "", - saltCharacters: UInt = 16, - stampSeconds: Bool = true, + bits: UInt = 10, date: String? = nil) -> String? { let ver = "1" @@ -25,15 +56,13 @@ public struct Hashcash { ts = date } else { let formatter = DateFormatter() - formatter.dateFormat = stampSeconds ? "yyMMddHHmmss" : "yyMMdd" + formatter.dateFormat = "yyMMddHHmmss" ts = formatter.string(from: Date()) } let challenge = "\(ver):\(bits):\(ts):\(resource):" var counter = 0 - let hexDigits = Int(ceil((Double(bits) / 4))) - let zeros = String(repeating: "0", count: hexDigits) while true { guard let digest = ("\(challenge):\(counter)").sha1 else { @@ -41,87 +70,27 @@ public struct Hashcash { return nil } - if digest.prefix(hexDigits) == zeros { + if digest == bits { return "\(challenge):\(counter)" } counter += 1 } } - - /** - Checks whether a stamp is valid - - parameter stamp: stamp to check e.g. 1:16:040922:foo::+ArSrtKd:164b3 - - parameter resource: resource to check against - - parameter bits: minimum bit value to check - - parameter expiration: number of seconds old the stamp may be - - returns: true if stamp is valid - */ - public func check(stamp: String, - resource: String? = nil, - bits: UInt, - expiration: UInt? = nil) -> Bool { - - guard let stamped = Stamp(stamp: stamp) else { - print("Invalid stamp format") - return false - } - - if let res = resource, res != stamped.resource { - print("Resources do not match") - return false - } - - var count = bits - if let claim = stamped.claim { - if bits > claim { - return false - } else { - count = claim - } - } - - if let expiration = expiration { - let goodUntilDate = Date(timeIntervalSinceNow: -TimeInterval(expiration)) - if (stamped.date < goodUntilDate) { - print("Stamp expired") - return false - } - } - - guard let digest = stamp.sha1 else { - return false - } - - let hexDigits = Int(ceil((Double(count) / 4))) - return digest.hasPrefix(String(repeating: "0", count: hexDigits)) - } - - /** - Generates random string of chosen length - - parameter length: length of random string - - returns: random string - */ - internal func salt(length: UInt) -> String { - let allowedCharacters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ+/=" - var result = "" - - for _ in 0.. 1 { - print("Version > 1. Not handled") - return nil - } - - if (self.version == 0 && components.count < 4) { - print("Not enough components for version 0") - return nil - } - - if (self.version == 1 && components.count < 7) { - print("Not enough components for version 1") - return nil - } - - if (self.version == 0) { - if let date = Stamp.parseDate(dateString: components[1]) { - self.date = date - } else { - return nil - } - self.resource = components[2] - self.suffix = components[3] - } else if (self.version == 1) { - if let claim = UInt(components[1]) { - self.claim = claim - } - if let date = Stamp.parseDate(dateString: components[2]) { - self.date = date - } else { - return nil - } - self.resource = components[3] - self.ext = components[4] - self.random = components[5] - self.counter = components[6] - } else { - return nil - } - } - - private static func parseDate(dateString: String) -> Date? { - let formatter = DateFormatter() - formatter.dateFormat = Stamp.DateFormatWithoutTime - - if let date = formatter.date(from: dateString) { - return date - } - - formatter.dateFormat = Stamp.DateFormatWithTime - - if let date = formatter.date(from: dateString) { - return date - } else { - print("Unable to parse date") - return nil - } - } -} 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 b161151..ecb93e7 100644 --- a/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift +++ b/Xcodes/AppleAPI/Tests/AppleAPITests/AppleAPITests.swift @@ -4,21 +4,24 @@ import XCTest final class AppleAPITests: XCTestCase { 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" - -// "1:11:20230224004345:8982e236688f6ebf588c4bd4b445c4cc::877" -// 7395f792caf430dca2d07ae7be0c63fa - + let stamp = Hashcash().mint(resource: resource, bits: bits, date: testDate) - XCTAssertNotNil(stamp) XCTAssertEqual(stamp, "1:10:20230224001754:bb63edf88d2f9c39f23eb4d6f0281158::866") - - print(stamp) } static var allTests = [ ("testValidHashCashMint", testValidHashCashMint), + ("testValidHashCashMint2", testValidHashCashMint2), ] }