Skip to content
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
4 changes: 3 additions & 1 deletion Sources/Real/AssertionController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
)

Expand Down
93 changes: 93 additions & 0 deletions Sources/Real/CredentialOutputs.swift
Original file line number Diff line number Diff line change
@@ -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) }
}
}
22 changes: 8 additions & 14 deletions Sources/Real/RegistrationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
)

Expand All @@ -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
)
)

Expand Down
82 changes: 78 additions & 4 deletions Sources/SimpleAuthenticationServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
}
}

Expand All @@ -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
}
}

Expand Down
3 changes: 2 additions & 1 deletion Sources/Virtual/Assertion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
3 changes: 2 additions & 1 deletion Sources/Virtual/Attestation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ func createAttestationResponse(rp: RelyingParty, cred: Credential, options: Atte
credentialID: cred.id,
rawClientDataJSON: clientDataJSON,
rawAttestationObject: attestationObjectBytes,
transports: []
transports: [],
attachment: .platform
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
Loading