diff --git a/Package.resolved b/Package.resolved index 23eeeb6..5ce43f4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -92,12 +92,21 @@ { "identity" : "swift-srp", "kind" : "remoteSourceControl", - "location" : "https://github.com/xcodesorg/swift-srp", + "location" : "https://github.com/xcodesOrg/swift-srp", "state" : { "branch" : "main", "revision" : "543aa0122a0257b992f6c7d62d18a26e3dffb8fe" } }, + { + "identity" : "swiftcbor", + "kind" : "remoteSourceControl", + "location" : "https://github.com/valpackett/SwiftCBOR.git", + "state" : { + "revision" : "fd929a6d8f7651ce0f63fc99397fd5762358cc29", + "version" : "0.6.0" + } + }, { "identity" : "swiftsoup", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 5d336ae..9d4bb54 100644 --- a/Package.swift +++ b/Package.swift @@ -23,7 +23,8 @@ let package = Package( .package(url: "https://github.com/xcodereleases/data", revision: "fcf527b187817f67c05223676341f3ab69d4214d"), .package(url: "https://github.com/onevcat/Rainbow.git", .upToNextMinor(from: "3.2.0")), .package(url: "https://github.com/jpsim/Yams", .upToNextMinor(from: "5.0.1")), - .package(url: "https://github.com/xcodesOrg/swift-srp", branch: "main") + .package(url: "https://github.com/xcodesOrg/swift-srp", branch: "main"), + .package(url: "https://github.com/hi2gage/swiftfido2.git", from: "0.0.2") ], targets: [ .executableTarget( @@ -69,7 +70,8 @@ let package = Package( "PromiseKit", .product(name: "PMKFoundation", package: "Foundation"), "Rainbow", - .product(name: "SRP", package: "swift-srp") + .product(name: "SRP", package: "swift-srp"), + .product(name: "SwiftFido2", package: "swiftfido2") ]), .testTarget( name: "AppleAPITests", diff --git a/Sources/AppleAPI/Client.swift b/Sources/AppleAPI/Client.swift index 06386cb..c191f70 100644 --- a/Sources/AppleAPI/Client.swift +++ b/Sources/AppleAPI/Client.swift @@ -5,6 +5,7 @@ import Rainbow import SRP import Crypto import CommonCrypto +import SwiftFido2 public class Client { private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"] @@ -236,7 +237,7 @@ public class Client { case .twoFactor: return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) case .hardwareKey: - throw Error.accountUsesHardwareKey + return self.handleHardwareKey(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions) case .unknown: Current.logging.log("Received a response from Apple that indicates this account has two-step or two-factor authentication enabled, but xcodes is unsure how to handle this response:".red) String(data: data, encoding: .utf8).map { Current.logging.log($0) } @@ -285,6 +286,100 @@ public class Client { } } + func handleHardwareKey(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise { + Current.logging.log("Hardware security key authentication is required for this account.\n") + + guard let fsaChallenge = authOptions.fsaChallenge else { + return Promise(error: Error.accountUsesHardwareKey) + } + + // Build the assertion request + let origin = "https://idmsa.apple.com" + let clientDataJSON = """ + {"type":"webauthn.get","challenge":"\(fsaChallenge.challenge)","origin":"\(origin)","crossOrigin":false} + """ + let clientDataJSONBytes = Data(clientDataJSON.utf8) + let clientDataHash = Data(CryptoKit.SHA256.hash(data: clientDataJSONBytes)) + + let credentialIds = fsaChallenge.allowedCredentials + .split(separator: ",") + .compactMap { Data(base64Encoded: base64urlToBase64(String($0))) } + .map { CredentialDescriptor(id: $0) } + + let request = AssertionRequest( + rpId: "apple.com", + clientDataHash: clientDataHash, + allowCredentials: credentialIds + ) + + // Discover and open FIDO device + Current.logging.log("Looking for your security key...") + + return Promise { seal in + Task { + do { + let client = FidoClient() + + let device: FidoDevice + do { + device = try await client.waitForDevice(timeoutSeconds: 30) + } catch { + Current.logging.log("No security key detected. Please plug in your key and try again.".red) + seal.reject(Error.accountUsesHardwareKey) + return + } + Current.logging.log("Found \(device.name)") + + Current.logging.log("Touch your security key...") + let assertion = try await client.getAssertion(device, request: request) + + // Build the response Apple expects + let challengeResponse = SecurityKeyResponse( + challenge: fsaChallenge.challenge, + clientData: clientDataJSONBytes.base64EncodedString(), + signatureData: assertion.signature.base64EncodedString(), + authenticatorData: assertion.authData.base64EncodedString(), + userHandle: assertion.userHandle.flatMap { String(data: $0, encoding: .utf8) } ?? "", + credentialID: assertion.credentialId.base64EncodedString(), + rpId: "apple.com" + ) + + let responseData = try JSONEncoder().encode(challengeResponse) + + Current.logging.log("Security key response received, submitting to Apple...") + + // Submit to Apple + let submitRequest = URLRequest.submitSecurityKeyAssertion( + serviceKey: serviceKey, + sessionID: sessionID, + scnt: scnt, + response: responseData + ) + + Current.network.dataTask(with: submitRequest) + .then { (data, response) -> Promise in + self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt) + } + .done { seal.fulfill(()) } + .catch { seal.reject($0) } + + } catch { + seal.reject(error) + } + } + } + } + + private func base64urlToBase64(_ input: String) -> String { + var base64 = input + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + while base64.count % 4 != 0 { + base64.append("=") + } + return base64 + } + func updateSession(serviceKey: String, sessionID: String, scnt: String) -> Promise { return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)) .then { (data, response) -> Promise in @@ -412,6 +507,16 @@ public class Client { } } +struct SecurityKeyResponse: Encodable { + let challenge: String + let clientData: String + let signatureData: String + let authenticatorData: String + let userHandle: String + let credentialID: String + let rpId: String +} + public extension Promise where T == (data: Data, response: URLResponse) { func validateSecurityCodeResponse() -> Promise { validate() diff --git a/Sources/AppleAPI/URLRequest+Apple.swift b/Sources/AppleAPI/URLRequest+Apple.swift index 47704ef..b350627 100644 --- a/Sources/AppleAPI/URLRequest+Apple.swift +++ b/Sources/AppleAPI/URLRequest+Apple.swift @@ -8,6 +8,7 @@ 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 securityKeyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")! static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")! static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")! @@ -109,6 +110,19 @@ extension URLRequest { return request } + static func submitSecurityKeyAssertion(serviceKey: String, sessionID: String, scnt: String, response: Data) -> URLRequest { + var request = URLRequest(url: .securityKeyAuth) + request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:] + request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID + request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey + request.allHTTPHeaderFields?["scnt"] = scnt + request.allHTTPHeaderFields?["Accept"] = "application/json" + request.allHTTPHeaderFields?["Content-Type"] = "application/json" + request.httpMethod = "POST" + request.httpBody = response + return request + } + static func trust(serviceKey: String, sessionID: String, scnt: String) -> URLRequest { var request = URLRequest(url: .trust) request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]