Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Security Key Auth #617

Merged
merged 6 commits into from
Oct 13, 2024
Merged
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
24 changes: 24 additions & 0 deletions Xcodes.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
objects = {

/* Begin PBXBuildFile section */
33027E342CA8C18800CB387C /* LibFido2Swift in Frameworks */ = {isa = PBXBuildFile; productRef = 334A932B2CA885A400A5E079 /* LibFido2Swift */; };
3328073F2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */; };
332807412CA5EA820036F691 /* SignInSecurityKeyTouchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */; };
36741BFD291E4FDB00A85AAE /* DownloadPreferencePane.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36741BFC291E4FDB00A85AAE /* DownloadPreferencePane.swift */; };
36741BFF291E50F500A85AAE /* FileError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36741BFE291E50F500A85AAE /* FileError.swift */; };
536CFDD2263C94DE00026CE0 /* SignedInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 536CFDD1263C94DE00026CE0 /* SignedInView.swift */; };
Expand Down Expand Up @@ -192,6 +195,8 @@
/* End PBXCopyFilesBuildPhase section */

/* Begin PBXFileReference section */
3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSecurityKeyPinView.swift; sourceTree = "<group>"; };
332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInSecurityKeyTouchView.swift; sourceTree = "<group>"; };
36741BFC291E4FDB00A85AAE /* DownloadPreferencePane.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DownloadPreferencePane.swift; sourceTree = "<group>"; };
36741BFE291E50F500A85AAE /* FileError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FileError.swift; sourceTree = "<group>"; };
536CFDD1263C94DE00026CE0 /* SignedInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignedInView.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -346,6 +351,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
33027E342CA8C18800CB387C /* LibFido2Swift in Frameworks */,
CABFA9E42592F08E00380FEE /* Version in Frameworks */,
CABFA9FD2592F13300380FEE /* LegibleError in Frameworks */,
E689540325BE8C64000EBCEA /* DockProgress in Frameworks */,
Expand Down Expand Up @@ -454,6 +460,8 @@
CA735108257BF96D00EA9CF8 /* AttributedText.swift */,
CA73510C257BFCEF00EA9CF8 /* NSAttributedString+.swift */,
CA5D781D257365D6008EDE9D /* PinCodeTextView.swift */,
3328073E2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift */,
332807402CA5EA820036F691 /* SignInSecurityKeyTouchView.swift */,
CAA1CB34255A5AD5003FD669 /* SignInCredentialsView.swift */,
CAA1CB44255A5B60003FD669 /* SignIn2FAView.swift */,
CAA1CB48255A5C97003FD669 /* SignInSMSView.swift */,
Expand Down Expand Up @@ -714,6 +722,7 @@
E8F44A1D296B4CD7002D6592 /* Path */,
E84E4F562B335094003F3959 /* OrderedCollections */,
E83FDC432CBB649100679C6B /* Sparkle */,
334A932B2CA885A400A5E079 /* LibFido2Swift */,
);
productName = XcodesMac;
productReference = CAD2E79E2449574E00113D76 /* Xcodes.app */;
Expand Down Expand Up @@ -802,6 +811,7 @@
E8F44A1C296B4CD7002D6592 /* XCRemoteSwiftPackageReference "Path" */,
E84E4F552B335094003F3959 /* XCRemoteSwiftPackageReference "swift-collections" */,
E83FDC422CBB649100679C6B /* XCRemoteSwiftPackageReference "Sparkle" */,
33027E282CA8BB5800CB387C /* XCRemoteSwiftPackageReference "LibFido2Swift" */,
);
productRefGroup = CAD2E79F2449574E00113D76 /* Products */;
projectDirPath = "";
Expand Down Expand Up @@ -889,6 +899,7 @@
CABFAA492593162500380FEE /* Bundle+InfoPlistValues.swift in Sources */,
CA9FF8662595130600E47BAF /* View+IsHidden.swift in Sources */,
CAE4248C259A68B800B8B246 /* Optional+IsNotNil.swift in Sources */,
3328073F2CA5E2C80036F691 /* SignInSecurityKeyPinView.swift in Sources */,
B0C6AD0D2AD91D7900E64698 /* IconView.swift in Sources */,
CA9FF9362595B44700E47BAF /* HelperClient.swift in Sources */,
B0C6AD042AD6E65700E64698 /* ReleaseDateView.swift in Sources */,
Expand All @@ -915,6 +926,7 @@
B0403CF22AD934B600137C09 /* CompatibilityView.swift in Sources */,
B0403CFE2ADA712C00137C09 /* InfoPaneControls.swift in Sources */,
53CBAB2C263DCC9100410495 /* XcodesAlert.swift in Sources */,
332807412CA5EA820036F691 /* SignInSecurityKeyTouchView.swift in Sources */,
CA61A6E0259835580008926E /* Xcode.swift in Sources */,
CAE4247F259A666100B8B246 /* MainWindow.swift in Sources */,
CA452BB0259FD9770072DFA4 /* ProgressIndicator.swift in Sources */,
Expand Down Expand Up @@ -1469,6 +1481,14 @@
/* End XCConfigurationList section */

/* Begin XCRemoteSwiftPackageReference section */
33027E282CA8BB5800CB387C /* XCRemoteSwiftPackageReference "LibFido2Swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kinoroy/LibFido2Swift.git";
requirement = {
kind = upToNextMinorVersion;
minimumVersion = 0.1.0;
};
};
CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/xcodereleases/data";
Expand Down Expand Up @@ -1568,6 +1588,10 @@
/* End XCRemoteSwiftPackageReference section */

/* Begin XCSwiftPackageProductDependency section */
334A932B2CA885A400A5E079 /* LibFido2Swift */ = {
isa = XCSwiftPackageProductDependency;
productName = LibFido2Swift;
};
CA9FF86C25951C6E00E47BAF /* XCModel */ = {
isa = XCSwiftPackageProductDependency;
package = CA9FF86B25951C6E00E47BAF /* XCRemoteSwiftPackageReference "data" */;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@
"version": "1.0.4"
}
},
{
"package": "LibFido2Swift",
"repositoryURL": "https://github.com/kinoroy/LibFido2Swift.git",
"state": {
"branch": null,
"revision": "b77e5c6451bea69d15615d6578936b11777d9a6c",
"version": "0.1.2"
}
},
{
"package": "Path.swift",
"repositoryURL": "https://github.com/mxcl/Path.swift",
Expand Down
52 changes: 47 additions & 5 deletions Xcodes/AppleAPI/Sources/AppleAPI/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ public class Client {
case .twoStep:
return Fail(error: AuthenticationError.accountUsesTwoStepAuthentication)
.eraseToAnyPublisher()
case .twoFactor:
case .twoFactor, .securityKey:
return self.handleTwoFactor(serviceKey: serviceKey, sessionID: sessionID, scnt: scnt, authOptions: authOptions)
.eraseToAnyPublisher()
case .unknown:
Expand All @@ -139,7 +139,10 @@ public class Client {
// SMS wasn't sent automatically because user needs to choose a phone to send to
} else if authOptions.canFallBackToSMS {
option = .smsPendingChoice
// Code is shown on trusted devices
// Code is shown on trusted devices
} else if authOptions.fsaChallenge != nil {
option = .securityKey
// User needs to use a physical security key to respond to the challenge
} else {
option = .codeSent
}
Expand Down Expand Up @@ -193,6 +196,33 @@ public class Client {
.eraseToAnyPublisher()
}

public func submitChallenge(response: Data, sessionData: AppleSessionData) -> AnyPublisher<AuthenticationState, Error> {
Result {
URLRequest.respondToChallenge(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt, response: response)
}
.publisher
.flatMap { request in
Current.network.dataTask(with: request)
.mapError { $0 as Error }
.tryMap { (data, response) throws -> (Data, URLResponse) in
guard let urlResponse = response as? HTTPURLResponse else { return (data, response) }
switch urlResponse.statusCode {
case 200..<300:
return (data, urlResponse)
case 400, 401:
throw AuthenticationError.incorrectSecurityCode
case 412:
throw AuthenticationError.appleIDAndPrivacyAcknowledgementRequired
case let code:
throw AuthenticationError.badStatusCode(statusCode: code, data: data, response: urlResponse)
}
}
.flatMap { (data, response) -> AnyPublisher<AuthenticationState, Error> in
self.updateSession(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt)
}
}.eraseToAnyPublisher()
}

// MARK: - Session

/// Use the olympus session endpoint to see if the existing session is still valid
Expand Down Expand Up @@ -326,34 +356,46 @@ public enum TwoFactorOption: Equatable {
case smsSent(AuthOptionsResponse.TrustedPhoneNumber)
case codeSent
case smsPendingChoice
case securityKey
}

public struct FSAChallenge: Equatable, Decodable {
public let challenge: String
public let keyHandles: [String]
public let allowedCredentials: String
}

public struct AuthOptionsResponse: Equatable, Decodable {
public let trustedPhoneNumbers: [TrustedPhoneNumber]?
public let trustedDevices: [TrustedDevice]?
public let securityCode: SecurityCodeInfo
public let securityCode: SecurityCodeInfo?
public let noTrustedDevices: Bool?
public let serviceErrors: [ServiceError]?
public let fsaChallenge: FSAChallenge?

public init(
trustedPhoneNumbers: [AuthOptionsResponse.TrustedPhoneNumber]?,
trustedDevices: [AuthOptionsResponse.TrustedDevice]?,
securityCode: AuthOptionsResponse.SecurityCodeInfo,
noTrustedDevices: Bool? = nil,
serviceErrors: [ServiceError]? = nil
serviceErrors: [ServiceError]? = nil,
fsaChallenge: FSAChallenge? = nil
) {
self.trustedPhoneNumbers = trustedPhoneNumbers
self.trustedDevices = trustedDevices
self.securityCode = securityCode
self.noTrustedDevices = noTrustedDevices
self.serviceErrors = serviceErrors
self.fsaChallenge = fsaChallenge
}

public var kind: Kind {
if trustedDevices != nil {
return .twoStep
} else if trustedPhoneNumbers != nil {
return .twoFactor
} else if fsaChallenge != nil {
return .securityKey
} else {
return .unknown
}
Expand Down Expand Up @@ -416,7 +458,7 @@ public struct AuthOptionsResponse: Equatable, Decodable {
}

public enum Kind: Equatable {
case twoStep, twoFactor, unknown
case twoStep, twoFactor, securityKey, unknown
}
}

Expand Down
14 changes: 14 additions & 0 deletions Xcodes/AppleAPI/Sources/AppleAPI/URLRequest+Apple.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public extension URL {
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")!
static let keyAuth = URL(string: "https://idmsa.apple.com/appleauth/auth/verify/security/key")!
}

public extension URLRequest {
Expand Down Expand Up @@ -105,6 +106,19 @@ public extension URLRequest {
}
return request
}

static func respondToChallenge(serviceKey: String, sessionID: String, scnt: String, response: Data) -> URLRequest {
var request = URLRequest(url: .keyAuth)
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)
Expand Down
62 changes: 62 additions & 0 deletions Xcodes/Backend/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Version
import os.log
import DockProgress
import XcodesKit
import LibFido2Swift

class AppState: ObservableObject {
private let client = AppleAPI.Client()
Expand Down Expand Up @@ -320,6 +321,67 @@ class AppState: ObservableObject {
.store(in: &cancellables)
}

var fido2: FIDO2?

func createAndSubmitSecurityKeyAssertationWithPinCode(_ pinCode: String, sessionData: AppleSessionData, authOptions: AuthOptionsResponse) {
self.presentedSheet = .securityKeyTouchToConfirm

guard let fsaChallenge = authOptions.fsaChallenge else {
// This shouldn't happen
// we shouldn't have called this method without setting the fsaChallenge
// so this is an assertionFailure
assertionFailure()
self.authError = "Something went wrong. Please file a bug report"
return
}

// The challenge is encoded in Base64URL encoding
let challengeUrl = fsaChallenge.challenge
let challenge = FIDO2.base64urlToBase64(base64url: challengeUrl)
let origin = "https://idmsa.apple.com"
let rpId = "apple.com"
// Allowed creds is sent as a comma separated string
let validCreds = fsaChallenge.allowedCredentials.split(separator: ",").map(String.init)

Task {
do {
let fido2 = FIDO2()
self.fido2 = fido2
let response = try fido2.respondToChallenge(args: ChallengeArgs(rpId: rpId, validCredentials: validCreds, devPin: pinCode, challenge: challenge, origin: origin))

Task { @MainActor in
self.isProcessingAuthRequest = true
}

let respData = try JSONEncoder().encode(response)
client.submitChallenge(response: respData, sessionData: AppleSessionData(serviceKey: sessionData.serviceKey, sessionID: sessionData.sessionID, scnt: sessionData.scnt))
.receive(on: DispatchQueue.main)
.handleEvents(
receiveOutput: { authenticationState in
self.authenticationState = authenticationState
},
receiveCompletion: { completion in
self.handleAuthenticationFlowCompletion(completion)
self.isProcessingAuthRequest = false
}
).sink(
receiveCompletion: { _ in },
receiveValue: { _ in }
).store(in: &cancellables)
} catch FIDO2Error.canceledByUser {
// User cancelled the auth flow
// we don't have to show an error
// because the sheet will already be dismissed
} catch {
authError = error
}
}
}

func cancelSecurityKeyAssertationRequest() {
self.fido2?.cancel()
}

private func handleAuthenticationFlowCompletion(_ completion: Subscribers.Completion<Error>) {
switch completion {
case let .failure(error):
Expand Down
6 changes: 5 additions & 1 deletion Xcodes/Frontend/Common/XcodesSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import AppleAPI
enum XcodesSheet: Identifiable {
case signIn
case twoFactor(SecondFactorData)
case securityKeyTouchToConfirm

var id: Int { Kind(self).hashValue }

Expand All @@ -16,12 +17,13 @@ enum XcodesSheet: Identifiable {

extension XcodesSheet {
private enum Kind: Hashable {
case signIn, twoFactor(TwoFactorOption)
case signIn, twoFactor(TwoFactorOption), securityKeyTouchToConfirm

enum TwoFactorOption {
case smsSent
case codeSent
case smsPendingChoice
case securityKeyPin
}

init(_ sheet: XcodesSheet) {
Expand All @@ -32,7 +34,9 @@ extension XcodesSheet {
case .smsSent: self = .twoFactor(.smsSent)
case .smsPendingChoice: self = .twoFactor(.smsPendingChoice)
case .codeSent: self = .twoFactor(.codeSent)
case .securityKey: self = .twoFactor(.securityKeyPin)
}
case .securityKeyTouchToConfirm: self = .securityKeyTouchToConfirm
}
}
}
Expand Down
5 changes: 5 additions & 0 deletions Xcodes/Frontend/MainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ struct MainWindow: View {
case .twoFactor(let secondFactorData):
secondFactorView(secondFactorData)
.environmentObject(appState)
case .securityKeyTouchToConfirm:
SignInSecurityKeyTouchView(isPresented: $appState.presentedSheet.isNotNil)
.environmentObject(appState)
}
}
.alert(item: $appState.presentedAlert, content: { presentedAlert in
Expand Down Expand Up @@ -107,6 +110,8 @@ struct MainWindow: View {
SignInSMSView(isPresented: $appState.presentedSheet.isNotNil, trustedPhoneNumber: trustedPhoneNumber, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
case .smsPendingChoice:
SignInPhoneListView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
case .securityKey:
SignInSecurityKeyPinView(isPresented: $appState.presentedSheet.isNotNil, authOptions: secondFactorData.authOptions, sessionData: secondFactorData.sessionData)
}
}

Expand Down
Loading