From f4378d5d8843b948ae85b9cde5273fd2c1bc1ed4 Mon Sep 17 00:00:00 2001 From: Martin Kellner Date: Tue, 20 Oct 2026 10:03:06 +0200 Subject: [PATCH] Expose authenticator attachment and client extension outputs Adds optional attachment (iOS 16.6+/macOS 13.5+) and largeBlob/prf extension outputs to the passkey credentials, and reports security-key transports as WebAuthn names (bluetooth -> ble, unknown values dropped). --- Sources/Real/AssertionController.swift | 4 +- Sources/Real/CredentialOutputs.swift | 93 +++++++++++++++++++ Sources/Real/RegistrationController.swift | 22 ++--- Sources/SimpleAuthenticationServices.swift | 82 +++++++++++++++- Sources/Virtual/Assertion.swift | 3 +- Sources/Virtual/Attestation.swift | 3 +- .../CredentialOutputsTests.swift | 46 +++++++++ .../VirtualAuthorization.swift | 3 + 8 files changed, 235 insertions(+), 21 deletions(-) create mode 100644 Sources/Real/CredentialOutputs.swift create mode 100644 Tests/SimpleAuthenticationServicesTests/CredentialOutputsTests.swift diff --git a/Sources/Real/AssertionController.swift b/Sources/Real/AssertionController.swift index 15a3f76..24423f1 100644 --- a/Sources/Real/AssertionController.swift +++ b/Sources/Real/AssertionController.swift @@ -52,7 +52,9 @@ public class AssertionController: NSObject, ASAuthorizationControllerDelegate, A rawClientDataJSON: r.rawClientDataJSON, rawAuthenticatorData: r.rawAuthenticatorData, signature: r.signature, - userID: r.userID + userID: r.userID, + attachment: passkeyAttachment(of: r), + clientExtensionResults: clientExtensionResults(of: r) ) ) diff --git a/Sources/Real/CredentialOutputs.swift b/Sources/Real/CredentialOutputs.swift new file mode 100644 index 0000000..723f8cb --- /dev/null +++ b/Sources/Real/CredentialOutputs.swift @@ -0,0 +1,93 @@ +import AuthenticationServices +import CryptoKit + +// Maps the authenticator attachment and client extension outputs reported by +// AuthenticationServices to the library's Sendable value types. + +@available(iOS 16.6, macOS 13.5, *) +func passkeyAttachment(_ attachment: ASAuthorizationPublicKeyCredentialAttachment) -> PasskeyAuthenticatorAttachment? { + switch attachment { + case .platform: + return .platform + case .crossPlatform: + return .crossPlatform + @unknown default: + return nil + } +} + +@available(iOS 16.0, macOS 13.0, *) +func passkeyAttachment(of assertion: ASAuthorizationPlatformPublicKeyCredentialAssertion) -> PasskeyAuthenticatorAttachment? { + if #available(iOS 16.6, macOS 13.5, *) { + return passkeyAttachment(assertion.attachment) + } + return nil +} + +@available(iOS 16.0, macOS 13.0, *) +func passkeyAttachment(of registration: ASAuthorizationPlatformPublicKeyCredentialRegistration) -> PasskeyAuthenticatorAttachment? { + if #available(iOS 16.6, macOS 13.5, *) { + return passkeyAttachment(registration.attachment) + } + return nil +} + +@available(iOS 16.0, macOS 13.0, *) +func clientExtensionResults(of assertion: ASAuthorizationPlatformPublicKeyCredentialAssertion) -> PasskeyClientExtensionResults { + var largeBlob: PasskeyClientExtensionResults.LargeBlob? + var prf: PasskeyClientExtensionResults.PRF? + + if #available(iOS 17.0, macOS 14.0, *), let output = assertion.largeBlob { + switch output.result { + case .read(let data): + largeBlob = .init(blob: data) + case .write(let success): + largeBlob = .init(written: success) + @unknown default: + break + } + } + + if #available(iOS 18.0, macOS 15.0, *), let output = assertion.prf { + prf = .init(first: output.first.rawData, second: output.second?.rawData) + } + + return PasskeyClientExtensionResults(largeBlob: largeBlob, prf: prf) +} + +@available(iOS 16.0, macOS 13.0, *) +func clientExtensionResults(of registration: ASAuthorizationPlatformPublicKeyCredentialRegistration) -> PasskeyClientExtensionResults { + var largeBlob: PasskeyClientExtensionResults.LargeBlob? + var prf: PasskeyClientExtensionResults.PRF? + + if #available(iOS 17.0, macOS 14.0, *), let output = registration.largeBlob { + largeBlob = .init(supported: output.isSupported) + } + + if #available(iOS 18.0, macOS 15.0, *), let output = registration.prf { + prf = .init(enabled: output.isSupported, first: output.first?.rawData, second: output.second?.rawData) + } + + return PasskeyClientExtensionResults(largeBlob: largeBlob, prf: prf) +} + +/// Maps security-key transports to WebAuthn transport names; unknown values are dropped. +@available(iOS 17.5, macOS 14.5, *) +func webAuthnTransportName(_ transport: ASAuthorizationSecurityKeyPublicKeyCredentialDescriptor.Transport) -> String? { + switch transport { + case .usb: + return "usb" + case .nfc: + return "nfc" + case .bluetooth: + return "ble" + default: + return nil + } +} + +private extension SymmetricKey { + var rawData: Data { + withUnsafeBytes { Data($0) } + } +} diff --git a/Sources/Real/RegistrationController.swift b/Sources/Real/RegistrationController.swift index fb21e6e..38dccea 100644 --- a/Sources/Real/RegistrationController.swift +++ b/Sources/Real/RegistrationController.swift @@ -30,12 +30,15 @@ public class RegistrationController: NSObject, ASAuthorizationControllerDelegate public func authorizationController(controller: ASAuthorizationController, didCompleteWithAuthorization authorization: ASAuthorization) { switch authorization.credential { case let r as ASAuthorizationPlatformPublicKeyCredentialRegistration: + // AuthenticationServices does not report transports for platform credentials. let response = AuthorizationResult( credential: PasskeyRegistrationCredential( credentialID: r.credentialID, rawClientDataJSON: r.rawClientDataJSON, rawAttestationObject: r.rawAttestationObject!, - transports: [] + transports: [], + attachment: passkeyAttachment(of: r), + clientExtensionResults: clientExtensionResults(of: r) ) ) @@ -45,26 +48,17 @@ public class RegistrationController: NSObject, ASAuthorizationControllerDelegate var transports: [Data] = [] if #available(iOS 17.5, *), #available(macOS 14.5, *) { - transports = r.transports.compactMap { transport in - switch transport { - case .usb: - return "usb".data(using: .utf8) - case .nfc: - return "nfc".data(using: .utf8) - case .bluetooth: - return "bluetooth".data(using: .utf8) - default: - return "unknown".data(using: .utf8) - } - } + transports = r.transports.compactMap { webAuthnTransportName($0)?.data(using: .utf8) } } + // Security keys are roaming authenticators by definition. let response = AuthorizationResult( credential: PasskeyRegistrationCredential( credentialID: r.credentialID, rawClientDataJSON: r.rawClientDataJSON, rawAttestationObject: r.rawAttestationObject!, - transports: transports + transports: transports, + attachment: .crossPlatform ) ) diff --git a/Sources/SimpleAuthenticationServices.swift b/Sources/SimpleAuthenticationServices.swift index 405c2aa..eab7b11 100644 --- a/Sources/SimpleAuthenticationServices.swift +++ b/Sources/SimpleAuthenticationServices.swift @@ -53,22 +53,89 @@ public protocol PublicKeyCredentialAssertion: PublicKeyCredential { public struct DummyProvider: AuthorizationProvider {} +/// The WebAuthn `authenticatorAttachment` of a credential (https://w3c.github.io/webauthn/#enumdef-authenticatorattachment). +public enum PasskeyAuthenticatorAttachment: String, Sendable, Equatable { + /// The authenticator is part of this device (e.g. iCloud Keychain on this iPhone). + case platform + /// A roaming authenticator, e.g. a security key or a phone used via hybrid (QR code). + case crossPlatform = "cross-platform" +} + +/// Client extension outputs reported by AuthenticationServices. +/// Fields are nil when the extension was not requested or the OS does not report it. +public struct PasskeyClientExtensionResults: Sendable, Equatable { + /// Output of the `largeBlob` extension (iOS 17+ / macOS 14+). + public struct LargeBlob: Sendable, Equatable { + /// Registration: whether the authenticator supports large blobs. + public let supported: Bool? + /// Assertion (read): the blob that was read. + public let blob: Data? + /// Assertion (write): whether the blob was written. + public let written: Bool? + + public init(supported: Bool? = nil, blob: Data? = nil, written: Bool? = nil) { + self.supported = supported + self.blob = blob + self.written = written + } + } + + /// Output of the `prf` extension (iOS 18+ / macOS 15+). + public struct PRF: Sendable, Equatable { + /// Registration: whether the credential supports PRF. + public let enabled: Bool? + /// The first PRF result, if evaluated. + public let first: Data? + /// The second PRF result, if evaluated with a second salt. + public let second: Data? + + public init(enabled: Bool? = nil, first: Data? = nil, second: Data? = nil) { + self.enabled = enabled + self.first = first + self.second = second + } + } + + public let largeBlob: LargeBlob? + public let prf: PRF? + + public init(largeBlob: LargeBlob? = nil, prf: PRF? = nil) { + self.largeBlob = largeBlob + self.prf = prf + } + + public var isEmpty: Bool { largeBlob == nil && prf == nil } +} + public struct PasskeyRegistrationCredential: PublicKeyCredentialRegistration { public let credentialID: Data public let rawClientDataJSON: Data public let rawAttestationObject: Data + /// WebAuthn transport names (e.g. "usb", "nfc", "ble", "hybrid", "internal") as UTF-8 bytes. public let transports: [Data] - + /// nil when the OS does not report it (below iOS 16.6 / macOS 13.5). + public let attachment: PasskeyAuthenticatorAttachment? + public let clientExtensionResults: PasskeyClientExtensionResults + public init( credentialID: Data, rawClientDataJSON: Data, rawAttestationObject: Data, - transports: [Data] + transports: [Data], + attachment: PasskeyAuthenticatorAttachment? = nil, + clientExtensionResults: PasskeyClientExtensionResults = PasskeyClientExtensionResults() ) { self.credentialID = credentialID self.rawClientDataJSON = rawClientDataJSON self.rawAttestationObject = rawAttestationObject self.transports = transports + self.attachment = attachment + self.clientExtensionResults = clientExtensionResults + } + + /// The reported transports as WebAuthn transport strings. + public var transportNames: [String] { + transports.compactMap { String(data: $0, encoding: .utf8) } } } @@ -78,19 +145,26 @@ public struct PasskeyAssertionCredential: PublicKeyCredentialAssertion { public let rawAuthenticatorData: Data public let signature: Data public let userID: Data - + /// nil when the OS does not report it (below iOS 16.6 / macOS 13.5). + public let attachment: PasskeyAuthenticatorAttachment? + public let clientExtensionResults: PasskeyClientExtensionResults + public init( credentialID: Data, rawClientDataJSON: Data, rawAuthenticatorData: Data, signature: Data, - userID: Data + userID: Data, + attachment: PasskeyAuthenticatorAttachment? = nil, + clientExtensionResults: PasskeyClientExtensionResults = PasskeyClientExtensionResults() ) { self.credentialID = credentialID self.rawClientDataJSON = rawClientDataJSON self.rawAuthenticatorData = rawAuthenticatorData self.signature = signature self.userID = userID + self.attachment = attachment + self.clientExtensionResults = clientExtensionResults } } diff --git a/Sources/Virtual/Assertion.swift b/Sources/Virtual/Assertion.swift index df5736f..277707a 100644 --- a/Sources/Virtual/Assertion.swift +++ b/Sources/Virtual/Assertion.swift @@ -82,6 +82,7 @@ func createAssertionResponse(rp: RelyingParty, credentials: [Credential], option rawClientDataJSON: clientDataJSON, rawAuthenticatorData: authenticatorData, signature: signature, - userID: cred.userID + userID: cred.userID, + attachment: .platform ) } diff --git a/Sources/Virtual/Attestation.swift b/Sources/Virtual/Attestation.swift index 544fa2c..f2cd076 100644 --- a/Sources/Virtual/Attestation.swift +++ b/Sources/Virtual/Attestation.swift @@ -129,7 +129,8 @@ func createAttestationResponse(rp: RelyingParty, cred: Credential, options: Atte credentialID: cred.id, rawClientDataJSON: clientDataJSON, rawAttestationObject: attestationObjectBytes, - transports: [] + transports: [], + attachment: .platform ) } diff --git a/Tests/SimpleAuthenticationServicesTests/CredentialOutputsTests.swift b/Tests/SimpleAuthenticationServicesTests/CredentialOutputsTests.swift new file mode 100644 index 0000000..258fb5b --- /dev/null +++ b/Tests/SimpleAuthenticationServicesTests/CredentialOutputsTests.swift @@ -0,0 +1,46 @@ +import AuthenticationServices +import Foundation +import Testing + +@testable import SimpleAuthenticationServices + +@Test func attachmentUsesWebAuthnValues() { + #expect(PasskeyAuthenticatorAttachment.platform.rawValue == "platform") + #expect(PasskeyAuthenticatorAttachment.crossPlatform.rawValue == "cross-platform") +} + +@available(iOS 16.6, macOS 13.5, *) +@Test func mapsNativeAttachment() { + #expect(passkeyAttachment(.platform) == .platform) + #expect(passkeyAttachment(.crossPlatform) == .crossPlatform) +} + +@available(iOS 17.5, macOS 14.5, *) +@Test func mapsSecurityKeyTransportsToWebAuthnNames() { + #expect(webAuthnTransportName(.usb) == "usb") + #expect(webAuthnTransportName(.nfc) == "nfc") + #expect(webAuthnTransportName(.bluetooth) == "ble") + #expect(webAuthnTransportName(.init(rawValue: "something-new")) == nil) +} + +@Test func newCredentialFieldsDefaultToUnreported() { + let registration = PasskeyRegistrationCredential( + credentialID: Data([1]), + rawClientDataJSON: Data(), + rawAttestationObject: Data(), + transports: ["usb", "ble"].map { Data($0.utf8) } + ) + #expect(registration.attachment == nil) + #expect(registration.clientExtensionResults.isEmpty) + #expect(registration.transportNames == ["usb", "ble"]) + + let assertion = PasskeyAssertionCredential( + credentialID: Data([1]), + rawClientDataJSON: Data(), + rawAuthenticatorData: Data(), + signature: Data(), + userID: Data() + ) + #expect(assertion.attachment == nil) + #expect(assertion.clientExtensionResults.isEmpty) +} diff --git a/Tests/SimpleAuthenticationServicesTests/VirtualAuthorization.swift b/Tests/SimpleAuthenticationServicesTests/VirtualAuthorization.swift index 407d0f3..137702c 100644 --- a/Tests/SimpleAuthenticationServicesTests/VirtualAuthorization.swift +++ b/Tests/SimpleAuthenticationServicesTests/VirtualAuthorization.swift @@ -173,6 +173,8 @@ private func create(rps: RelyingPartyServer, authenticator: VirtualAuthorization authorizationResult.credential as? PasskeyRegistrationCredential, "Credential must be of type PasskeyRegistrationCredential. Actual type: \(type(of: authorizationResult.credential))" ) + #expect(typedCredential.attachment == .platform) + #expect(typedCredential.clientExtensionResults.isEmpty) let registrationData = RPPlatformPublicKeyCredentialRegistration( id: typedCredential.credentialID.toBase64URL(), @@ -208,6 +210,7 @@ private func finishLogin(rps: RelyingPartyServer, authenticatorResponse: Authori authenticatorResponse.credential as? PasskeyAssertionCredential, "Credential must be of type PasskeyAssertionCredential. Actual type: \(type(of: authenticatorResponse.credential))" ) + #expect(assertionTyped.attachment == .platform) let loginFinishData = RPPlatformPublicKeyCredentialAssertion( id: assertionTyped.credentialID.toBase64URL(),