diff --git a/.gitignore b/.gitignore index 3a709847..b9219cf7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ webauthn-rs-demo-wasm/pkg .DS_Store local build.sh +.swiftpm +.build diff --git a/webauthn-authenticator-rs/Cargo.toml b/webauthn-authenticator-rs/Cargo.toml index 2693dcbd..92752dfb 100644 --- a/webauthn-authenticator-rs/Cargo.toml +++ b/webauthn-authenticator-rs/Cargo.toml @@ -43,6 +43,7 @@ softpasskey = ["crypto", "softtoken"] softtoken = ["crypto", "ctap2"] usb = ["ctap2", "dep:fido-hid-rs"] win10 = ["dep:windows"] +macos = ["dep:swift-rs"] default = [] @@ -71,6 +72,7 @@ authenticator = { version = "0.3.2-dev.1", optional = true, default-features = f pcsc = { git = "https://github.com/bluetech/pcsc-rust.git", rev = "13e24649be96989cdffb7e73ca3a994b9534ddff", optional = true } windows = { version = "0.41.0", optional = true, features = ["Win32_Graphics_Gdi", "Win32_Networking_WindowsWebServices", "Win32_Foundation", "Win32_UI_WindowsAndMessaging", "Win32_System_LibraryLoader", "Win32_Graphics_Dwm" ] } +swift-rs = { version = "1.0.5", optional = true } serde.workspace = true bitflags = "1.3.2" unicode-normalization = "0.1.22" @@ -107,6 +109,7 @@ image = ">= 0.23.14, < 0.24" [build-dependencies] openssl = { workspace = true, optional = true } +swift-rs = { version = "1.0.5", optional = true, features = ["build"] } [[example]] name = "authenticate" diff --git a/webauthn-authenticator-rs/build.rs b/webauthn-authenticator-rs/build.rs index 6cd57012..81c04e8a 100644 --- a/webauthn-authenticator-rs/build.rs +++ b/webauthn-authenticator-rs/build.rs @@ -63,7 +63,17 @@ Please upgrade to OpenSSL v3.0.0 or later. } } +#[cfg(feature = "macos")] +fn macos() { + swift_rs::SwiftLinker::new("12") + .with_package("MacAuthn", "./src/MacAuthn") + .link(); +} + fn main() { #[cfg(feature = "crypto")] crypto::test_openssl(); + + #[cfg(feature = "macos")] + macos(); } diff --git a/webauthn-authenticator-rs/examples/authenticate.rs b/webauthn-authenticator-rs/examples/authenticate.rs index 21fa0ac3..9784b52a 100644 --- a/webauthn-authenticator-rs/examples/authenticate.rs +++ b/webauthn-authenticator-rs/examples/authenticate.rs @@ -159,6 +159,10 @@ enum Provider { #[cfg(feature = "win10")] /// Windows 10 WebAuthn API, supporting BTLE, NFC and USB HID. Win10, + + #[cfg(feature = "macos")] + /// MacOS Authorization API + MacOS, } impl Provider { @@ -209,6 +213,8 @@ impl Provider { Provider::Mozilla => Box::::default(), #[cfg(feature = "win10")] Provider::Win10 => Box::::default(), + #[cfg(feature = "macos")] + Provider::MacOS => Box::::default(), } } } diff --git a/webauthn-authenticator-rs/src/MacAuthn/Package.resolved b/webauthn-authenticator-rs/src/MacAuthn/Package.resolved new file mode 100644 index 00000000..c78ab01c --- /dev/null +++ b/webauthn-authenticator-rs/src/MacAuthn/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-rs", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Brendonovich/swift-rs", + "state" : { + "revision" : "a1578d5808b22b5a3461d36a6c8add5cd83a2ddf", + "version" : "1.0.5" + } + } + ], + "version" : 2 +} diff --git a/webauthn-authenticator-rs/src/MacAuthn/Package.swift b/webauthn-authenticator-rs/src/MacAuthn/Package.swift new file mode 100644 index 00000000..0564582d --- /dev/null +++ b/webauthn-authenticator-rs/src/MacAuthn/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version: 5.8 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "MacAuthn", + platforms: [ + .macOS(.v12) + ], + products: [ + .library( + name: "MacAuthn", + type: .static, + targets: ["MacAuthn"]), + ], + dependencies: [ + .package(url: "https://github.com/Brendonovich/swift-rs", from: "1.0.5") + ], + targets: [ + .target( + name: "MacAuthn", + dependencies: [ + .product( + name: "SwiftRs", + package: "swift-rs" + ), + ]), + ] +) diff --git a/webauthn-authenticator-rs/src/MacAuthn/README.md b/webauthn-authenticator-rs/src/MacAuthn/README.md new file mode 100644 index 00000000..cb6ecb0e --- /dev/null +++ b/webauthn-authenticator-rs/src/MacAuthn/README.md @@ -0,0 +1,3 @@ +# MacAuthn + +This package is built by `build.rs` using `webauthn-rs`. diff --git a/webauthn-authenticator-rs/src/MacAuthn/Sources/MacAuthn/MacAuthn.swift b/webauthn-authenticator-rs/src/MacAuthn/Sources/MacAuthn/MacAuthn.swift new file mode 100644 index 00000000..0cced4d6 --- /dev/null +++ b/webauthn-authenticator-rs/src/MacAuthn/Sources/MacAuthn/MacAuthn.swift @@ -0,0 +1,194 @@ +import AuthenticationServices +import SwiftRs +import Cocoa + +enum Result { + case ok([String: Any]) + case error(String) +} + +class ApplicationDelegate: NSObject, NSApplicationDelegate, ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding { + let window: NSWindow + let authController: ASAuthorizationController + var result: Result = .error("task did not finish") + + init(window: NSWindow, authController: ASAuthorizationController) { + self.window = window + self.authController = authController + } + + func applicationDidFinishLaunching(_ notification: Notification) { + authController.delegate = self + authController.presentationContextProvider = self + authController.performRequests() + } + + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + return window + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { + if let credential = authorization.credential as? ASAuthorizationSecurityKeyPublicKeyCredentialRegistration { + let rawId = credential.credentialID.toBase64Url() + let clientDataJSON = credential.rawClientDataJSON.toBase64Url() + let attestationObject = credential.rawAttestationObject!.toBase64Url() + self.result = .ok([ + "id": rawId, + "rawId": rawId, + "type": "public-key", + "response": [ + "clientDataJSON": clientDataJSON, + "attestationObject": attestationObject + ] + ]) + } else if let credential = authorization.credential as? ASAuthorizationSecurityKeyPublicKeyCredentialAssertion { + let signature = credential.signature.toBase64Url() + let clientDataJSON = credential.rawClientDataJSON.toBase64Url() + let authenticatorData = credential.rawAuthenticatorData.toBase64Url() + let rawId = credential.credentialID.toBase64Url() + self.result = .ok([ + "id": rawId, + "rawId": rawId, + "type": "public-key", + "response": [ + "clientDataJSON": clientDataJSON, + "authenticatorData": authenticatorData, + "signature": signature + ] + ]) + } else if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration { + let rawId = credential.credentialID.toBase64Url() + let clientDataJSON = credential.rawClientDataJSON.toBase64Url() + let attestationObject = credential.rawAttestationObject!.toBase64Url() + self.result = .ok([ + "id": rawId, + "rawId": rawId, + "type": "public-key", + "response": [ + "clientDataJSON": clientDataJSON, + "attestationObject": attestationObject + ] + ]) + } else if let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion { + let signature = credential.signature.toBase64Url() + let clientDataJSON = credential.rawClientDataJSON.toBase64Url() + let authenticatorData = credential.rawAuthenticatorData.toBase64Url() + let rawId = credential.credentialID.toBase64Url() + self.result = .ok([ + "id": rawId, + "rawId": rawId, + "type": "public-key", + "response": [ + "clientDataJSON": clientDataJSON, + "authenticatorData": authenticatorData, + "signature": signature + ] + ]) + } else { + self.result = .error("unhandled credential") + } + NSApplication.shared.stop(0) + } + + func authorizationController(controller: ASAuthorizationController, didCompleteWithError error: Error) { + self.result = .error(error.localizedDescription) + NSApplication.shared.stop(0) + } +} + +// ASAuthorizationController expects an ASPresentationAnchor (a NSWindow) +// in order to know where to place itself. This function can create such +// a window and run the NSRunLoop event loop to completion for it. +func run(authController: ASAuthorizationController) -> String { + NSApplication.shared.setActivationPolicy(.regular) + let window = NSWindow(contentRect: NSMakeRect(0, 0, 1, 1), styleMask: .borderless, backing: .buffered, defer: false) + window.center() + window.makeKeyAndOrderFront(window) + + let applicationDelegate = ApplicationDelegate(window: window, authController: authController) + NSApplication.shared.delegate = applicationDelegate + + NSApplication.shared.activate(ignoringOtherApps: true) + NSApplication.shared.run() + + // Rust expects one of either {"data": ...} or {"error": ...} + switch applicationDelegate.result { + case let .ok(data): + return String(data: try! JSONSerialization.data(withJSONObject: ["data": data]), encoding: .utf8)! + case let .error(message): + return String(data: try! JSONSerialization.data(withJSONObject: ["error": message]), encoding: .utf8)! + } +} + +@_cdecl("perform_register") +public func performRegister(options: SRString) -> SRString { + let options = try! JSONDecoder().decode(PublicKeyCredentialCreationOptions.self, from: Data(options.toArray())) + + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rp.id) + let platformKeyRequest = platformProvider.createCredentialRegistrationRequest(challenge: options.challenge.decodeBase64Url()!, name: options.user.name, userID: options.user.id.decodeBase64Url()!) + platformKeyRequest.displayName = options.user.displayName + platformKeyRequest.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreference.init(rawValue: options.authenticatorSelection.userVerification ?? "preferred") + + let securityKeyProvider = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: options.rp.id) + + let securityKeyRequest = securityKeyProvider.createCredentialRegistrationRequest(challenge: options.challenge.decodeBase64Url()!, displayName: options.user.displayName, name: options.user.name, userID: options.user.id.decodeBase64Url()!) + + securityKeyRequest.credentialParameters = [] + for publicKeyParam in options.pubKeyCredParams { + let algorithm = ASCOSEAlgorithmIdentifier(rawValue: publicKeyParam.alg) + let parameters = ASAuthorizationPublicKeyCredentialParameters(algorithm: algorithm) + securityKeyRequest.credentialParameters.append(parameters) + } + + securityKeyRequest.excludedCredentials = [] + for credential in (options.excludeCredentials ?? []) { + let id = credential.id.decodeBase64Url()! + let transports = credential.transports?.map { + ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport.init(rawValue: $0) + } ?? ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport.allSupported + let credential = ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor(credentialID: id, transports: transports) + securityKeyRequest.excludedCredentials.append(credential) + } + + securityKeyRequest.attestationPreference = ASAuthorizationPublicKeyCredentialAttestationKind(rawValue: options.attestation ?? "none") + securityKeyRequest.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreference.init(rawValue: options.authenticatorSelection.userVerification ?? "preferred") + + if options.authenticatorSelection.requireResidentKey == true { + securityKeyRequest.residentKeyPreference = .required + } else { + securityKeyRequest.residentKeyPreference = .preferred + } + + let authController = ASAuthorizationController(authorizationRequests: [platformKeyRequest, securityKeyRequest]) + + return SRString(run(authController: authController)) +} + +@_cdecl("perform_auth") +public func performAuth(options: SRString) -> SRString { + let options = try! JSONDecoder().decode(PublicKeyCredentialRequestOptions.self, from: Data(options.toArray())) + + let securityKeyProvider = ASAuthorizationSecurityKeyPublicKeyCredentialProvider(relyingPartyIdentifier: options.rpId) + let securityKeyRequest = securityKeyProvider.createCredentialAssertionRequest(challenge: options.challenge.decodeBase64Url()!) + + let platformProvider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: options.rpId) + let platformKeyRequest = platformProvider.createCredentialAssertionRequest(challenge: options.challenge.decodeBase64Url()!) + + securityKeyRequest.userVerificationPreference = ASAuthorizationPublicKeyCredentialUserVerificationPreference.init(rawValue: options.userVerification ?? "preferred") + + securityKeyRequest.allowedCredentials = [] + for credential in (options.allowCredentials ?? []) { + let id = credential.id.decodeBase64Url()! + let transports = credential.transports?.map { + ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport.init(rawValue: $0) + } ?? ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport.allSupported + let descriptor = ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor(credentialID: id, transports: transports) + securityKeyRequest.allowedCredentials.append(descriptor) + } + // Setting allowedCredentials can hang for some reason: https://developer.apple.com/forums/thread/727267 + securityKeyRequest.allowedCredentials = [] + + let authController = ASAuthorizationController(authorizationRequests: [platformKeyRequest, securityKeyRequest]) + + return SRString(run(authController: authController)) +} diff --git a/webauthn-authenticator-rs/src/MacAuthn/Sources/MacAuthn/Protocol.swift b/webauthn-authenticator-rs/src/MacAuthn/Sources/MacAuthn/Protocol.swift new file mode 100644 index 00000000..e6afa648 --- /dev/null +++ b/webauthn-authenticator-rs/src/MacAuthn/Sources/MacAuthn/Protocol.swift @@ -0,0 +1,75 @@ +// +// File.swift +// +// +// Created by snek on 2023-08-05. +// + +import Foundation + +struct PublicKeyCredentialDescriptor: Decodable { + let id: String + let transports: [String]? +} + +struct PublicKeyCredentialUserEntity: Decodable { + let id: String + let name: String + let displayName: String +} + +struct PublicKeyCredentialRpEntity: Decodable { + let id: String + let name: String +} + +struct PublicKeyCredentialParameters: Decodable { + let type: String + let alg: Int +} + +struct AuthenticatorSelectionCriteria: Decodable { + let userVerification: String? + let requireResidentKey: Bool? +} + +struct PublicKeyCredentialCreationOptions: Decodable { + let rp: PublicKeyCredentialRpEntity + let user: PublicKeyCredentialUserEntity + let challenge: String + let pubKeyCredParams: [PublicKeyCredentialParameters] + let timeout: Double + let excludeCredentials: [PublicKeyCredentialDescriptor]? + let authenticatorSelection: AuthenticatorSelectionCriteria + let attestation: String? +} + +struct PublicKeyCredentialRequestOptions: Decodable { + let challenge: String + let timeout: Double + let rpId: String + let allowCredentials: [PublicKeyCredentialDescriptor]? + let userVerification: String? +} + +extension String { + func decodeBase64Url() -> Data? { + var base64 = self + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + if base64.count % 4 != 0 { + base64.append(String(repeating: "=", count: 4 - base64.count % 4)) + } + return Data(base64Encoded: base64) + } +} + +extension Data { + func toBase64Url() -> String { + return self + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} diff --git a/webauthn-authenticator-rs/src/error.rs b/webauthn-authenticator-rs/src/error.rs index 082b0aad..919de7be 100644 --- a/webauthn-authenticator-rs/src/error.rs +++ b/webauthn-authenticator-rs/src/error.rs @@ -69,6 +69,10 @@ pub enum WebauthnCError { /// something has not been initialised correctly, or that the authenticator /// is sending unexpected messages. UnexpectedState, + /// An error occured in ASAuthorization. + #[cfg(feature = "macos")] + ASAuthorization(String), + SerdeJson, } #[cfg(feature = "nfc")] @@ -141,6 +145,12 @@ impl From for WebauthnCError { } } +impl From for WebauthnCError { + fn from(_v: serde_json::Error) -> Self { + WebauthnCError::SerdeJson + } +} + /// #[derive(Debug, PartialEq, Eq)] pub enum CtapError { diff --git a/webauthn-authenticator-rs/src/lib.rs b/webauthn-authenticator-rs/src/lib.rs index 5e109eaf..fcce07f0 100644 --- a/webauthn-authenticator-rs/src/lib.rs +++ b/webauthn-authenticator-rs/src/lib.rs @@ -45,6 +45,7 @@ //! * `softtoken`: [SoftToken][] (for testing) [^openssl] //! * `usb`: [USB HID][] [^openssl] //! * `win10`: [Windows 10][] WebAuthn API +//! * `macos`: [MacOS][] WebAuthn API //! //! [^openssl]: Feature requires OpenSSL. //! @@ -83,6 +84,7 @@ //! [SoftToken]: crate::softtoken //! [USB HID]: crate::usb //! [Windows 10]: crate::win10 +//! [MacOS]: crate::macos #![cfg_attr(docsrs, feature(doc_cfg))] #![cfg_attr(docsrs, feature(doc_auto_cfg))] @@ -169,6 +171,9 @@ pub mod u2fhid { #[cfg(any(all(doc, not(doctest)), feature = "win10"))] pub mod win10; +#[cfg(any(all(doc, not(doctest)), feature = "macos"))] +pub mod macos; + #[cfg(doc)] #[doc(hidden)] mod stubs; diff --git a/webauthn-authenticator-rs/src/macos.rs b/webauthn-authenticator-rs/src/macos.rs new file mode 100644 index 00000000..044ef8bd --- /dev/null +++ b/webauthn-authenticator-rs/src/macos.rs @@ -0,0 +1,57 @@ +use crate::{AuthenticatorBackend, Url, WebauthnCError}; +use webauthn_rs_proto::{ + PublicKeyCredential, PublicKeyCredentialCreationOptions, PublicKeyCredentialRequestOptions, + RegisterPublicKeyCredential, +}; + +mod sys { + use swift_rs::{swift, SRString}; + + swift!(pub fn perform_register(options: SRString) -> SRString); + swift!(pub fn perform_auth(options: SRString) -> SRString); + + #[derive(serde::Deserialize)] + pub enum Result { + #[serde(rename = "data")] + Data(T), + #[serde(rename = "error")] + Error(String), + } +} + +/// Authenticator backend for MacOS ASAuthorization API. +#[derive(Default)] +pub struct MacOS {} + +impl AuthenticatorBackend for MacOS { + /// Perform a registration action using the ASAuthorization API. + fn perform_register( + &mut self, + _origin: Url, + options: PublicKeyCredentialCreationOptions, + _timeout_ms: u32, + ) -> Result { + let result = + unsafe { sys::perform_register(serde_json::to_string(&options)?.as_str().into()) }; + + match serde_json::from_str::>(result.as_str())? { + sys::Result::Data(data) => Ok(data), + sys::Result::Error(s) => Err(WebauthnCError::ASAuthorization(s)), + } + } + + /// Perform an authentication action using the ASAuthorization API. + fn perform_auth( + &mut self, + _origin: Url, + options: PublicKeyCredentialRequestOptions, + _timeout_ms: u32, + ) -> Result { + let result = unsafe { sys::perform_auth(serde_json::to_string(&options)?.as_str().into()) }; + + match serde_json::from_str::>(result.as_str())? { + sys::Result::Data(data) => Ok(data), + sys::Result::Error(s) => Err(WebauthnCError::ASAuthorization(s)), + } + } +}