Skip to content

Commit 1c832bc

Browse files
committed
Add hardware security key authentication support
1 parent 93bce87 commit 1c832bc

File tree

4 files changed

+134
-4
lines changed

4 files changed

+134
-4
lines changed

Package.resolved

Lines changed: 10 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ let package = Package(
2323
.package(url: "https://github.com/xcodereleases/data", revision: "fcf527b187817f67c05223676341f3ab69d4214d"),
2424
.package(url: "https://github.com/onevcat/Rainbow.git", .upToNextMinor(from: "3.2.0")),
2525
.package(url: "https://github.com/jpsim/Yams", .upToNextMinor(from: "5.0.1")),
26-
.package(url: "https://github.com/xcodesOrg/swift-srp", branch: "main")
26+
.package(url: "https://github.com/xcodesOrg/swift-srp", branch: "main"),
27+
.package(url: "https://github.com/hi2gage/swiftfido2.git", from: "0.0.2")
2728
],
2829
targets: [
2930
.executableTarget(
@@ -69,7 +70,8 @@ let package = Package(
6970
"PromiseKit",
7071
.product(name: "PMKFoundation", package: "Foundation"),
7172
"Rainbow",
72-
.product(name: "SRP", package: "swift-srp")
73+
.product(name: "SRP", package: "swift-srp"),
74+
.product(name: "SwiftFido2", package: "swiftfido2")
7375
]),
7476
.testTarget(
7577
name: "AppleAPITests",

Sources/AppleAPI/Client.swift

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Rainbow
55
import SRP
66
import Crypto
77
import CommonCrypto
8+
import SwiftFido2
89

910
public class Client {
1011
private static let authTypes = ["sa", "hsa", "non-sa", "hsa2"]
@@ -236,7 +237,7 @@ public class Client {
236237
case .twoFactor:
237238
return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
238239
case .hardwareKey:
239-
throw Error.accountUsesHardwareKey
240+
return self.handleHardwareKey(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
240241
case .unknown:
241242
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)
242243
String(data: data, encoding: .utf8).map { Current.logging.log($0) }
@@ -285,6 +286,100 @@ public class Client {
285286
}
286287
}
287288

289+
func handleHardwareKey(serviceKey: String, sessionID: String, scnt: String, authOptions: AuthOptionsResponse) -> Promise<Void> {
290+
Current.logging.log("Hardware security key authentication is required for this account.\n")
291+
292+
guard let fsaChallenge = authOptions.fsaChallenge else {
293+
return Promise(error: Error.accountUsesHardwareKey)
294+
}
295+
296+
// Build the assertion request
297+
let origin = "https://idmsa.apple.com"
298+
let clientDataJSON = """
299+
{"type":"webauthn.get","challenge":"\(fsaChallenge.challenge)","origin":"\(origin)","crossOrigin":false}
300+
"""
301+
let clientDataJSONBytes = Data(clientDataJSON.utf8)
302+
let clientDataHash = Data(CryptoKit.SHA256.hash(data: clientDataJSONBytes))
303+
304+
let credentialIds = fsaChallenge.allowedCredentials
305+
.split(separator: ",")
306+
.compactMap { Data(base64Encoded: base64urlToBase64(String($0))) }
307+
.map { CredentialDescriptor(id: $0) }
308+
309+
let request = AssertionRequest(
310+
rpId: "apple.com",
311+
clientDataHash: clientDataHash,
312+
allowCredentials: credentialIds
313+
)
314+
315+
// Discover and open FIDO device
316+
Current.logging.log("Looking for your security key...")
317+
318+
return Promise { seal in
319+
Task {
320+
do {
321+
let client = FidoClient()
322+
323+
let device: FidoDevice
324+
do {
325+
device = try await client.waitForDevice(timeoutSeconds: 30)
326+
} catch {
327+
Current.logging.log("No security key detected. Please plug in your key and try again.".red)
328+
seal.reject(Error.accountUsesHardwareKey)
329+
return
330+
}
331+
Current.logging.log("Found \(device.name)")
332+
333+
Current.logging.log("Touch your security key...")
334+
let assertion = try await client.getAssertion(device, request: request)
335+
336+
// Build the response Apple expects
337+
let challengeResponse = SecurityKeyResponse(
338+
challenge: fsaChallenge.challenge,
339+
clientData: clientDataJSONBytes.base64EncodedString(),
340+
signatureData: assertion.signature.base64EncodedString(),
341+
authenticatorData: assertion.authData.base64EncodedString(),
342+
userHandle: assertion.userHandle.flatMap { String(data: $0, encoding: .utf8) } ?? "",
343+
credentialID: assertion.credentialId.base64EncodedString(),
344+
rpId: "apple.com"
345+
)
346+
347+
let responseData = try JSONEncoder().encode(challengeResponse)
348+
349+
Current.logging.log("Security key response received, submitting to Apple...")
350+
351+
// Submit to Apple
352+
let submitRequest = URLRequest.submitSecurityKeyAssertion(
353+
serviceKey: serviceKey,
354+
sessionID: sessionID,
355+
scnt: scnt,
356+
response: responseData
357+
)
358+
359+
Current.network.dataTask(with: submitRequest)
360+
.then { (data, response) -> Promise<Void> in
361+
self.updateSession(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt)
362+
}
363+
.done { seal.fulfill(()) }
364+
.catch { seal.reject($0) }
365+
366+
} catch {
367+
seal.reject(error)
368+
}
369+
}
370+
}
371+
}
372+
373+
private func base64urlToBase64(_ input: String) -> String {
374+
var base64 = input
375+
.replacingOccurrences(of: "-", with: "+")
376+
.replacingOccurrences(of: "_", with: "/")
377+
while base64.count % 4 != 0 {
378+
base64.append("=")
379+
}
380+
return base64
381+
}
382+
288383
func updateSession(serviceKey: String, sessionID: String, scnt: String) -> Promise<Void> {
289384
return Current.network.dataTask(with: URLRequest.trust(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt))
290385
.then { (data, response) -> Promise<Void> in
@@ -412,6 +507,16 @@ public class Client {
412507
}
413508
}
414509

510+
struct SecurityKeyResponse: Encodable {
511+
let challenge: String
512+
let clientData: String
513+
let signatureData: String
514+
let authenticatorData: String
515+
let userHandle: String
516+
let credentialID: String
517+
let rpId: String
518+
}
519+
415520
public extension Promise where T == (data: Data, response: URLResponse) {
416521
func validateSecurityCodeResponse() -> Promise<T> {
417522
validate()

Sources/AppleAPI/URLRequest+Apple.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ extension URL {
88
static let requestSecurityCode = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/phone")!
99
static func submitSecurityCode(_ code: SecurityCode) -> URL { URL(string: "https://idmsa.apple.com/appleauth/auth/verify/\(code.urlPathComponent)/securitycode")! }
1010
static let trust = URL(string: "https://idmsa.apple.com/appleauth/auth/2sv/trust")!
11+
static let securityKeyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")!
1112
static let olympusSession = URL(string: "https://appstoreconnect.apple.com/olympus/v1/session")!
1213

1314
static let srpInit = URL(string: "https://idmsa.apple.com/appleauth/auth/signin/init")!
@@ -109,6 +110,19 @@ extension URLRequest {
109110
return request
110111
}
111112

113+
static func submitSecurityKeyAssertion(serviceKey: String, sessionID: String, scnt: String, response: Data) -> URLRequest {
114+
var request = URLRequest(url: .securityKeyAuth)
115+
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]
116+
request.allHTTPHeaderFields?["X-Apple-ID-Session-Id"] = sessionID
117+
request.allHTTPHeaderFields?["X-Apple-Widget-Key"] = serviceKey
118+
request.allHTTPHeaderFields?["scnt"] = scnt
119+
request.allHTTPHeaderFields?["Accept"] = "application/json"
120+
request.allHTTPHeaderFields?["Content-Type"] = "application/json"
121+
request.httpMethod = "POST"
122+
request.httpBody = response
123+
return request
124+
}
125+
112126
static func trust(serviceKey: String, sessionID: String, scnt: String) -> URLRequest {
113127
var request = URLRequest(url: .trust)
114128
request.allHTTPHeaderFields = request.allHTTPHeaderFields ?? [:]

0 commit comments

Comments
 (0)