Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand Down
107 changes: 106 additions & 1 deletion Sources/AppleAPI/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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) }
Expand Down Expand Up @@ -285,6 +286,100 @@ public class Client {
}
}

func handleHardwareKey(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise<Void> {
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<Void> 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<Void> {
return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
.then { (data, response) -> Promise<Void> in
Expand Down Expand Up @@ -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<T> {
validate()
Expand Down
14 changes: 14 additions & 0 deletions Sources/AppleAPI/URLRequest+Apple.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
Expand Down Expand Up @@ -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 ?? [:]
Expand Down