From 1c14d1b9684d888d9509071f3f3f16a3b737445a Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sat, 24 Feb 2024 05:34:22 -0800 Subject: [PATCH 01/29] Configured minimum requirements for other apple platforms --- Package.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index 127e953f..0756b9c4 100644 --- a/Package.swift +++ b/Package.swift @@ -18,7 +18,10 @@ import PackageDescription let package = Package( name: "swift-webauthn", platforms: [ - .macOS(.v13) + .macOS(.v13), + .iOS(.v16), + .tvOS(.v16), + .watchOS(.v9), ], products: [ .library(name: "WebAuthn", targets: ["WebAuthn"]) From 60e6815eb2a1eeba79463997bf4709fc2037a308 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sat, 24 Feb 2024 03:12:15 -0800 Subject: [PATCH 02/29] Added new AuthenticatorProtocol for modular authenticators --- .../AuthenticatorCredentialSourceProtocol.swift | 17 +++++++++++++++++ .../Protocol/AuthenticatorProtocol.swift | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift create mode 100644 Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift new file mode 100644 index 00000000..9e89ffe9 --- /dev/null +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift @@ -0,0 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the WebAuthn Swift open source project +// +// Copyright (c) 2024 the WebAuthn Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public protocol AuthenticatorCredentialSourceProtocol: Sendable { + +} diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift new file mode 100644 index 00000000..364768a5 --- /dev/null +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift @@ -0,0 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the WebAuthn Swift open source project +// +// Copyright (c) 2024 the WebAuthn Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public protocol AuthenticatorProtocol { + associatedtype CredentialSource: AuthenticatorCredentialSourceProtocol +} From b90a0f4e16a002c0101d6f1a41223b7020192aef Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sat, 24 Feb 2024 02:52:20 -0800 Subject: [PATCH 03/29] Added AuthenticatorCredentialSourceIdentifier for representing credential source identifiers --- ...thenticatorCredentialSourceIdentifier.swift | 18 ++++++++++++++++++ ...AuthenticatorCredentialSourceProtocol.swift | 7 ++++++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift new file mode 100644 index 00000000..9f72bb8a --- /dev/null +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift @@ -0,0 +1,18 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the WebAuthn Swift open source project +// +// Copyright (c) 2024 the WebAuthn Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +public protocol AuthenticatorCredentialSourceIdentifier: Hashable { + init?(bytes: some BidirectionalCollection) + var bytes: [UInt8] { get } +} diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift index 9e89ffe9..47320c23 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift @@ -12,6 +12,11 @@ // //===----------------------------------------------------------------------===// -public protocol AuthenticatorCredentialSourceProtocol: Sendable { +public protocol AuthenticatorCredentialSourceProtocol: Sendable, Identifiable where ID: AuthenticatorCredentialSourceIdentifier { + var id: ID { get } + + init( + id: ID + ) throws } From bab5128afa8fc33b701bcffbd9c62a44fc1fe023 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 26 Feb 2024 04:31:16 -0800 Subject: [PATCH 04/29] Updated PublicKeyCredentialParameters to be hashable --- .../PublicKeyCredentialCreationOptions.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift b/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift index 61d044bc..78e4136e 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift @@ -71,7 +71,7 @@ public struct PublicKeyCredentialCreationOptions: Encodable, Sendable { // MARK: - Credential parameters /// From §5.3 (https://w3c.github.io/TR/webauthn/#dictionary-credential-params) -public struct PublicKeyCredentialParameters: Equatable, Encodable, Sendable { +public struct PublicKeyCredentialParameters: Hashable, Encodable, Sendable { /// The type of credential to be created. At the time of writing always ``CredentialType/publicKey``. public let type: CredentialType /// The cryptographic signature algorithm with which the newly generated credential will be used, and thus also @@ -99,6 +99,13 @@ extension Array where Element == PublicKeyCredentialParameters { } } +extension Set where Element == PublicKeyCredentialParameters { + /// A list of `PublicKeyCredentialParameters` WebAuthn Swift currently supports. + public static var supported: Self { + Set([PublicKeyCredentialParameters].supported) + } +} + // MARK: - Credential entities /// From §5.4.2 (https://www.w3.org/TR/webauthn/#sctn-rp-credential-params). From 2b1fe1ad0c9ce0b3ffb15b8bdff9598690733e16 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sat, 24 Feb 2024 03:25:19 -0800 Subject: [PATCH 05/29] Updated AuthenticatorProtocol to include essential members for registration and authentication --- ...uthenticatorCredentialSourceProtocol.swift | 14 ++- .../Protocol/AuthenticatorProtocol.swift | 97 +++++++++++++++++++ .../PublicKeyCredentialCreationOptions.swift | 4 +- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift index 47320c23..efa858f9 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift @@ -12,11 +12,19 @@ // //===----------------------------------------------------------------------===// +import Foundation +import Crypto + public protocol AuthenticatorCredentialSourceProtocol: Sendable, Identifiable where ID: AuthenticatorCredentialSourceIdentifier { var id: ID { get } + var credentialParameters: PublicKeyCredentialParameters { get } + var relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID { get } + var userHandle: PublicKeyCredentialUserEntity.ID { get } + var counter: UInt32 { get } - init( - id: ID - ) throws + func signAssertion( + authenticatorData: [UInt8], + clientDataHash: SHA256Digest + ) async throws -> [UInt8] } diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift index 364768a5..45d32fd4 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift @@ -12,6 +12,103 @@ // //===----------------------------------------------------------------------===// +import Crypto +import SwiftCBOR + public protocol AuthenticatorProtocol { associatedtype CredentialSource: AuthenticatorCredentialSourceProtocol + + var attestationGloballyUniqueID: AAGUID { get } + var attachmentModality: AuthenticatorAttachment { get } + var supportedPublicKeyCredentialParameters: Set { get } + var canPerformUserVerification: Bool { get } + var canStoreCredentialSourceClientSide: Bool { get } + + /// Generate a credential source for this authenticator. + /// - Parameters: + /// - requiresClientSideKeyStorage: `true` if the relying party requires that the credential ID is stored client size, as it won't be provided during authentication requests. + /// - credentialParameters: The chosen credential parameters. + /// - relyingPartyID: The ID of the relying party the credential is being generated for. + /// - userHandle: The user handle the credential is being generated for. + /// - Returns: A new credential source to be returned to the caller upon successful registration. + func generateCredentialSource( + requiresClientSideKeyStorage: Bool, + credentialParameters: PublicKeyCredentialParameters, + relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID, + userHandle: PublicKeyCredentialUserEntity.ID + ) async throws -> CredentialSource + + /// The preferred attestation format for the authenticator, optionally taking into account the provided list of formats the relying party prefers. + /// + /// The default implementation returns ``AttestationFormat/none``. + /// + /// - Parameter attestationFormats: A list of attestation formats the relying party prefers. + /// - Returns: The attestation format that will be used to sign an attestation statement. + func preferredAttestationFormat(from attestationFormats: [AttestationFormat]) -> AttestationFormat + + /// Sign an attestation statement for the provided authenticator data and client data using the specified format. + /// - Parameters: + /// - attestationFormat: The attestation format to sign with. + /// - authenticatorData: The authenticator data to be signed. + /// - clientDataHash: The client data to be signed. + /// - Returns: A signiture in the specified format. + func signAttestationStatement( + attestationFormat: AttestationFormat, + authenticatorData: [UInt8], + clientDataHash: SHA256.Digest + ) async throws -> CBOR + + /// Filter the provided credential descriptors to determine which, if any, should be handled by this authenticator. + /// + /// This method should execute a client platform-specific procedure to determine which, if any, public key credentials described by `pkOptions.allowCredentials` are bound to this authenticator, by matching with `rpId`, `pkOptions.allowCredentials.id`, and `pkOptions.allowCredentials.type` + /// + /// The default implementation returns the list as is. + /// - Parameters: + /// - credentialDescriptors: A list of credentials that will be used assert authorization against. + /// - relyingPartyID: The relying party ID the credentials belong to. + /// - Returns: A filtered list of credentials that are suitable for this authenticator. + func filteredCredentialDescriptors( + credentialDescriptors: [PublicKeyCredentialDescriptor], + relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID + ) -> [PublicKeyCredentialDescriptor] + + /// Collect an authorization gesture from the user for one of the specified credential sources, making sure to increment the counter for the credential source if relevant. + /// - Parameters: + /// - requiresUserVerification: The user is required to verify that the credential should be used to assert authorization. If the user cannot perform this task, this method should throw an error. + /// - requiresUserPresence: The user is required to be present in order for authorization to be attempted. ie. authorization should not be done in the background without the user's knowledge while they are away from this device. + /// - credentialOptions: A list of available credentials to verify against. + /// - Returns: The chosen credential to use for authorization. + func collectAuthorizationGesture( + requiresUserVerification: Bool, + requiresUserPresence: Bool, + credentialOptions: [CredentialSource] + ) async throws -> CredentialSource +} + +// MARK: - Default Implementations + +extension AuthenticatorProtocol { + public func preferredAttestationFormat( + from attestationFormats: [AttestationFormat] + ) -> AttestationFormat { + .none + } + + public func signAttestationStatement( + attestationFormat: AttestationFormat, + authenticatorData: [UInt8], + clientDataHash: SHA256.Digest + ) async throws -> CBOR { + guard attestationFormat == .none + else { throw WebAuthnError.attestationFormatNotSupported } + + return [:] + } + + public func filteredCredentialDescriptors( + credentialDescriptors: [PublicKeyCredentialDescriptor], + relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID + ) -> [PublicKeyCredentialDescriptor] { + return credentialDescriptors + } } diff --git a/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift b/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift index 78e4136e..47b789a8 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift @@ -111,7 +111,7 @@ extension Set where Element == PublicKeyCredentialParameters { /// From §5.4.2 (https://www.w3.org/TR/webauthn/#sctn-rp-credential-params). /// The PublicKeyCredentialRelyingPartyEntity dictionary is used to supply additional Relying Party attributes when /// creating a new credential. -public struct PublicKeyCredentialRelyingPartyEntity: Encodable, Sendable { +public struct PublicKeyCredentialRelyingPartyEntity: Identifiable, Encodable, Sendable { /// A unique identifier for the Relying Party entity. public let id: String @@ -126,7 +126,7 @@ public struct PublicKeyCredentialRelyingPartyEntity: Encodable, Sendable { /// creating a new credential. /// /// When encoding using `Encodable`, `id` is base64url encoded. -public struct PublicKeyCredentialUserEntity: Encodable, Sendable { +public struct PublicKeyCredentialUserEntity: Identifiable, Encodable, Sendable { /// Generated by the Relying Party, unique to the user account, and must not contain personally identifying /// information about the user. /// From 8d7b74f0077175d195a1f534aa7cc15337580962 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Thu, 15 Feb 2024 02:36:59 -0800 Subject: [PATCH 06/29] Updated AuthenticatorData and related objects to be byte encodable --- .../Registration/AttestationObject.swift | 23 +++++++++++++++++++ .../Ceremonies/Shared/AuthenticatorData.swift | 23 +++++++++++++++++++ .../Shared/AuthenticatorFlags.swift | 13 ++++++++++- 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift b/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift index 11d2b031..ec10522b 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift @@ -22,6 +22,29 @@ public struct AttestationObject: Sendable { let rawAuthenticatorData: [UInt8] let format: AttestationFormat let attestationStatement: CBOR + + init( + authenticatorData: AuthenticatorData, + rawAuthenticatorData: [UInt8], + format: AttestationFormat, + attestationStatement: CBOR + ) { + self.authenticatorData = authenticatorData + self.rawAuthenticatorData = rawAuthenticatorData + self.format = format + self.attestationStatement = attestationStatement + } + + init( + authenticatorData: AuthenticatorData, + format: AttestationFormat, + attestationStatement: CBOR + ) { + self.authenticatorData = authenticatorData + self.rawAuthenticatorData = authenticatorData.bytes + self.format = format + self.attestationStatement = attestationStatement + } func verify( relyingPartyID: String, diff --git a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift index dafd865c..1e06bdc7 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift @@ -120,6 +120,29 @@ extension AuthenticatorData { return (data, length) } + + public var bytes: [UInt8] { + assert(relyingPartyIDHash.count == 32, "AuthenticatorData contains relyingPartyIDHash of length \(relyingPartyIDHash.count), which will likely not be decodable.") + var bytes: [UInt8] = [] + + bytes += relyingPartyIDHash + bytes += flags.bytes + bytes += withUnsafeBytes(of: UInt32(counter).bigEndian) { Array($0) } + + assert((!flags.attestedCredentialData && attestedData == nil) || (flags.attestedCredentialData && attestedData != nil), "AuthenticatorData contains mismatch between attestedCredentialData flag and attestedData, which will likely not be decodable.") + if flags.attestedCredentialData, let attestedData { + bytes += attestedData.authenticatorAttestationGUID.bytes + bytes += withUnsafeBytes(of: UInt16(attestedData.credentialID.count).bigEndian) { Array($0) } + bytes += attestedData.credentialID + bytes += attestedData.publicKey + } + + assert((!flags.extensionDataIncluded && extData == nil) || (flags.extensionDataIncluded && extData != nil), "AuthenticatorData contains mismatch between extensionDataIncluded flag and extData, which will likely not be decodable.") + if flags.extensionDataIncluded, let extData { + bytes += extData + } + return bytes + } } /// A helper type to determine how many bytes were consumed when decoding CBOR items. diff --git a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift index cfe66a43..09d55ea8 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -struct AuthenticatorFlags: Equatable, Sendable { +public struct AuthenticatorFlags: Equatable, Sendable { /** Taken from https://w3c.github.io/webauthn/#sctn-authenticator-data @@ -58,4 +58,15 @@ extension AuthenticatorFlags { attestedCredentialData = Self.isFlagSet(on: byte, at: .attestedCredentialDataIncluded) extensionDataIncluded = Self.isFlagSet(on: byte, at: .extensionDataIncluded) } + + public var bytes: [UInt8] { + var byte: UInt8 = 0 + byte |= userPresent ? 1 << Bit.userPresent.rawValue : 0 + byte |= userVerified ? 1 << Bit.userVerified.rawValue : 0 + byte |= isBackupEligible ? 1 << Bit.backupEligible.rawValue : 0 + byte |= isCurrentlyBackedUp ? 1 << Bit.backupState.rawValue : 0 + byte |= attestedCredentialData ? 1 << Bit.attestedCredentialDataIncluded.rawValue : 0 + byte |= extensionDataIncluded ? 1 << Bit.extensionDataIncluded.rawValue : 0 + return [byte] + } } From e6bd5b45b3e2da11dfd1a11439bb828564d296fc Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sat, 24 Feb 2024 03:35:43 -0800 Subject: [PATCH 07/29] Added AttestationRegistrationRequest to assist with registration attestations --- .../AttestationRegistrationRequest.swift | 59 +++++++++++++++++++ .../Protocol/AuthenticatorProtocol.swift | 15 +++++ .../Registration/AttestedCredentialData.swift | 2 +- .../Ceremonies/Shared/AuthenticatorData.swift | 2 +- 4 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift diff --git a/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift b/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift new file mode 100644 index 00000000..a53c23bf --- /dev/null +++ b/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the WebAuthn Swift open source project +// +// Copyright (c) 2024 the WebAuthn Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +@preconcurrency import Crypto +import SwiftCBOR + +public struct AttestationRegistrationRequest: Sendable { + var options: PublicKeyCredentialCreationOptions + var publicKeyCredentialParameters: [PublicKeyCredentialParameters] + var clientDataHash: SHA256Digest + var attemptRegistration: Callback + + init( + options: PublicKeyCredentialCreationOptions, + publicKeyCredentialParameters: [PublicKeyCredentialParameters], + clientDataHash: SHA256Digest, + attemptRegistration: @Sendable @escaping (_ attestationObject: AttestationObject) async throws -> () + ) { + self.options = options + self.publicKeyCredentialParameters = publicKeyCredentialParameters + self.clientDataHash = clientDataHash + self.attemptRegistration = Callback(callback: attemptRegistration) + } +} + +extension AttestationRegistrationRequest { + public struct Callback: Sendable { + /// The internal callback the attestation should call. + var callback: @Sendable (_ attestationObject: AttestationObject) async throws -> () + + /// Generate an attestation object for registration and submit it. + /// + /// Authenticators should call this to submit a successful registration and cancel any other pending authenticators. + /// + /// - SeeAlso: https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object + public func submitAttestationObject( + attestationFormat: AttestationFormat, + authenticatorData: AuthenticatorData, + attestationStatement: CBOR + ) async throws { + try await callback(AttestationObject( + authenticatorData: authenticatorData, + format: attestationFormat, + attestationStatement: attestationStatement + )) + } + } +} diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift index 45d32fd4..9c271481 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift @@ -58,6 +58,11 @@ public protocol AuthenticatorProtocol { clientDataHash: SHA256.Digest ) async throws -> CBOR + /// Make credentials for the specified registration request, returning the credential source that the caller should store for subsequent authentication. + /// + /// - Important: Depending on the authenticator being used, the credential source may contain private keys, and must be stored sequirely, such as in the user's Keychain, or in a Hardware Security Module appropriate with the level of security you wish to secure your user's account with. + func makeCredentials(with registration: AttestationRegistrationRequest) async throws -> CredentialSource + /// Filter the provided credential descriptors to determine which, if any, should be handled by this authenticator. /// /// This method should execute a client platform-specific procedure to determine which, if any, public key credentials described by `pkOptions.allowCredentials` are bound to this authenticator, by matching with `rpId`, `pkOptions.allowCredentials.id`, and `pkOptions.allowCredentials.type` @@ -112,3 +117,13 @@ extension AuthenticatorProtocol { return credentialDescriptors } } + +// MARK: Registration + +extension AuthenticatorProtocol { + public func makeCredentials( + with registration: AttestationRegistrationRequest + ) async throws -> CredentialSource { + throw WebAuthnError.unsupported + } +} diff --git a/Sources/WebAuthn/Ceremonies/Registration/AttestedCredentialData.swift b/Sources/WebAuthn/Ceremonies/Registration/AttestedCredentialData.swift index 71f4bf60..7d66912e 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AttestedCredentialData.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AttestedCredentialData.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// // Contains the new public key created by the authenticator. -struct AttestedCredentialData: Equatable { +public struct AttestedCredentialData: Equatable, Sendable { let authenticatorAttestationGUID: AAGUID let credentialID: [UInt8] let publicKey: [UInt8] diff --git a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift index 1e06bdc7..47564e10 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift @@ -18,7 +18,7 @@ import SwiftCBOR /// Data created and/ or used by the authenticator during authentication/ registration. /// The data contains, for example, whether a user was present or verified. -struct AuthenticatorData: Equatable, Sendable { +public struct AuthenticatorData: Equatable, Sendable { let relyingPartyIDHash: [UInt8] let flags: AuthenticatorFlags let counter: UInt32 From ea34e761bac72ad5e009872338d8b86ba8c36f62 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sun, 25 Feb 2024 02:36:30 -0800 Subject: [PATCH 08/29] Updated PublicKeyCredentialParameters to be Codable --- .../PublicKeyCredentialCreationOptions.swift | 14 +++++++++++--- .../Shared/COSE/COSEAlgorithmIdentifier.swift | 2 +- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift b/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift index 47b789a8..6ae303a7 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift @@ -71,7 +71,7 @@ public struct PublicKeyCredentialCreationOptions: Encodable, Sendable { // MARK: - Credential parameters /// From §5.3 (https://w3c.github.io/TR/webauthn/#dictionary-credential-params) -public struct PublicKeyCredentialParameters: Hashable, Encodable, Sendable { +public struct PublicKeyCredentialParameters: Hashable, Codable, Sendable { /// The type of credential to be created. At the time of writing always ``CredentialType/publicKey``. public let type: CredentialType /// The cryptographic signature algorithm with which the newly generated credential will be used, and thus also @@ -111,7 +111,7 @@ extension Set where Element == PublicKeyCredentialParameters { /// From §5.4.2 (https://www.w3.org/TR/webauthn/#sctn-rp-credential-params). /// The PublicKeyCredentialRelyingPartyEntity dictionary is used to supply additional Relying Party attributes when /// creating a new credential. -public struct PublicKeyCredentialRelyingPartyEntity: Identifiable, Encodable, Sendable { +public struct PublicKeyCredentialRelyingPartyEntity: Identifiable, Codable, Sendable { /// A unique identifier for the Relying Party entity. public let id: String @@ -126,7 +126,7 @@ public struct PublicKeyCredentialRelyingPartyEntity: Identifiable, Encodable, Se /// creating a new credential. /// /// When encoding using `Encodable`, `id` is base64url encoded. -public struct PublicKeyCredentialUserEntity: Identifiable, Encodable, Sendable { +public struct PublicKeyCredentialUserEntity: Identifiable, Codable, Sendable { /// Generated by the Relying Party, unique to the user account, and must not contain personally identifying /// information about the user. /// @@ -150,6 +150,14 @@ public struct PublicKeyCredentialUserEntity: Identifiable, Encodable, Sendable { self.displayName = displayName } + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decodeBytesFromURLEncodedBase64(forKey: .id) + self.name = try container.decode(String.self, forKey: .name) + self.displayName = try container.decode(String.self, forKey: .displayName) + } + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) diff --git a/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEAlgorithmIdentifier.swift b/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEAlgorithmIdentifier.swift index fb4a172f..93953bdf 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEAlgorithmIdentifier.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEAlgorithmIdentifier.swift @@ -18,7 +18,7 @@ import Crypto /// COSEAlgorithmIdentifier From §5.10.5. A number identifying a cryptographic algorithm. The algorithm /// identifiers SHOULD be values registered in the IANA COSE Algorithms registry /// [https://www.w3.org/TR/webauthn/#biblio-iana-cose-algs-reg], for instance, -7 for "ES256" and -257 for "RS256". -public enum COSEAlgorithmIdentifier: Int, RawRepresentable, CaseIterable, Encodable, Sendable { +public enum COSEAlgorithmIdentifier: Int, RawRepresentable, CaseIterable, Codable, Sendable { /// AlgES256 ECDSA with SHA-256 case algES256 = -7 /// AlgES384 ECDSA with SHA-384 From 7bc6bda045b43d8e7631d5b0e5761eb3baef2e64 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sat, 24 Feb 2024 03:01:30 -0800 Subject: [PATCH 09/29] Added KeyPairAuthenticator for authenticating with unattested private keys as data --- .../Authenticators/KeyPairAuthenticator.swift | 142 ++++++++++++++++++ ...henticatorCredentialSourceIdentifier.swift | 21 +++ Sources/WebAuthn/WebAuthnError.swift | 8 + 3 files changed, 171 insertions(+) create mode 100644 Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift diff --git a/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift b/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift new file mode 100644 index 00000000..f192c7b6 --- /dev/null +++ b/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift @@ -0,0 +1,142 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the WebAuthn Swift open source project +// +// Copyright (c) 2024 the WebAuthn Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import Crypto + +public struct KeyPairAuthenticator: AuthenticatorProtocol, Sendable { + public let attestationGloballyUniqueID: AAGUID + public let attachmentModality: AuthenticatorAttachment + public let supportedPublicKeyCredentialParameters: Set + public let canPerformUserVerification: Bool = true + public let canStoreCredentialSourceClientSide: Bool = true + + /// Initialize a key-pair based authenticator with a globally unique ID representing your application. + /// - Note: To generate an AAGUID, run `% uuidgen` in your terminal. This value should generally not change across installations or versions of your app, and should be the same for every user. + /// - Parameter attestationGloballyUniqueID: The AAGUID associated with the authenticator. + /// - Parameter attachmentModality: The connected-nature of the authenticator to the device the client is running on. If credential keys can roam between devices, specify ``AuthenticatorModality/crossPlatform``. Set to ``AuthenticatorModality/platform`` by default. + /// - Parameter supportedPublicKeyCredentialParameters: A customized set of key credentials the authenticator will limit support to. + public init( + attestationGloballyUniqueID: AAGUID, + attachmentModality: AuthenticatorAttachment = .platform, + supportedPublicKeyCredentialParameters: Set = .supported + ) { + self.attestationGloballyUniqueID = attestationGloballyUniqueID + self.attachmentModality = attachmentModality + self.supportedPublicKeyCredentialParameters = supportedPublicKeyCredentialParameters + } + + public func generateCredentialSource( + requiresClientSideKeyStorage: Bool, + credentialParameters: PublicKeyCredentialParameters, + relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID, + userHandle: PublicKeyCredentialUserEntity.ID + ) async throws -> CredentialSource { + throw WebAuthnError.unsupported + } + + public func filteredCredentialDescriptors( + credentialDescriptors: [PublicKeyCredentialDescriptor], + relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID + ) -> [PublicKeyCredentialDescriptor] { + return credentialDescriptors + } + + public func collectAuthorizationGesture( + requiresUserVerification: Bool, + requiresUserPresence: Bool, + credentialOptions: [CredentialSource] + ) async throws -> CredentialSource { + guard let credentialSource = credentialOptions.first + else { throw WebAuthnError.authorizationGestureNotAllowed } + + return credentialSource + } +} + +extension KeyPairAuthenticator { + public struct CredentialSource: AuthenticatorCredentialSourceProtocol, Sendable { + public var id: UUID + public var relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID + public var userHandle: PublicKeyCredentialUserEntity.ID + public var counter: UInt32 + + public var credentialParameters: PublicKeyCredentialParameters { + PublicKeyCredentialParameters(alg: .algES256) + } + + public var rawKeyData: Data { + Data() + } + + public init( + id: ID, + credentialParameters: PublicKeyCredentialParameters, + rawKeyData: some ContiguousBytes, + relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID, + userHandle: PublicKeyCredentialUserEntity.ID, + counter: UInt32 + ) throws { + guard credentialParameters.type == .publicKey + else { throw WebAuthnError.unsupportedCredentialPublicKeyType } + + self.id = id + self.relyingPartyID = relyingPartyID + self.userHandle = userHandle + self.counter = counter + } + + public func signAssertion( + authenticatorData: [UInt8], + clientDataHash: SHA256Digest + ) throws -> [UInt8] { + throw WebAuthnError.unsupported + } + } +} + +extension KeyPairAuthenticator.CredentialSource: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + try self.init( + id: try container.decode(UUID.self, forKey: .id), + credentialParameters: try container.decode(PublicKeyCredentialParameters.self, forKey: .credentialParameters), + rawKeyData: try container.decode(Data.self, forKey: .key), + relyingPartyID: try container.decode(String.self, forKey: .relyingPartyID), + userHandle: PublicKeyCredentialUserEntity.ID(try container.decode(Data.self, forKey: .userHandle)), + counter: try container.decode(UInt32.self, forKey: .counter) + ) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(credentialParameters, forKey: .credentialParameters) + try container.encode(rawKeyData, forKey: .key) + try container.encode(relyingPartyID, forKey: .relyingPartyID) + try container.encode(Data(userHandle), forKey: .userHandle) + try container.encode(counter, forKey: .counter) + } + + enum CodingKeys: CodingKey { + case id + case credentialParameters + case key + case relyingPartyID + case userHandle + case counter + } +} diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift index 9f72bb8a..ea64d9aa 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift @@ -12,7 +12,28 @@ // //===----------------------------------------------------------------------===// +import Foundation + public protocol AuthenticatorCredentialSourceIdentifier: Hashable { init?(bytes: some BidirectionalCollection) var bytes: [UInt8] { get } } + +extension UUID: AuthenticatorCredentialSourceIdentifier { + public init?(bytes: some BidirectionalCollection) { + let uuidSize = MemoryLayout.size + guard bytes.count == uuidSize else { return nil } + + /// Either load it directly, or copy it to a new array to load the uuid from there. + let uuid = bytes.withContiguousStorageIfAvailable { + $0.withUnsafeBytes { + $0.loadUnaligned(as: uuid_t.self) + } + } ?? Array(bytes).withUnsafeBytes { + $0.loadUnaligned(as: uuid_t.self) + } + self = UUID(uuid: uuid) + } + + public var bytes: [UInt8] { withUnsafeBytes(of: self) { Array($0) } } +} diff --git a/Sources/WebAuthn/WebAuthnError.swift b/Sources/WebAuthn/WebAuthnError.swift index be0c215f..b91ad8dd 100644 --- a/Sources/WebAuthn/WebAuthnError.swift +++ b/Sources/WebAuthn/WebAuthnError.swift @@ -66,6 +66,10 @@ public struct WebAuthnError: Error, Hashable, Sendable { case invalidExponent case unsupportedCOSEAlgorithmForRSAPublicKey case unsupported + + // MARK: Authenticator + case unsupportedCredentialPublicKeyType + case authorizationGestureNotAllowed } let reason: Reason @@ -125,4 +129,8 @@ public struct WebAuthnError: Error, Hashable, Sendable { public static let invalidExponent = Self(reason: .invalidExponent) public static let unsupportedCOSEAlgorithmForRSAPublicKey = Self(reason: .unsupportedCOSEAlgorithmForRSAPublicKey) public static let unsupported = Self(reason: .unsupported) + + // MARK: Authenticator + public static let unsupportedCredentialPublicKeyType = Self(reason: .unsupportedCredentialPublicKeyType) + public static let authorizationGestureNotAllowed = Self(reason: .authorizationGestureNotAllowed) } From 9c97beac7fee51478e373df833b982e3846c96df Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 12 Feb 2024 04:09:00 -0800 Subject: [PATCH 10/29] Added start of WebAuthn client registration implementation --- .../Authenticators/KeyPairAuthenticator.swift | 7 +- .../Protocol/AuthenticatorProtocol.swift | 3 + Sources/WebAuthn/WebAuthnClient.swift | 235 ++++++++++++++++++ Sources/WebAuthn/WebAuthnError.swift | 8 + Sources/WebAuthn/WebAuthnManager.swift | 2 +- 5 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 Sources/WebAuthn/WebAuthnClient.swift diff --git a/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift b/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift index f192c7b6..8ccf92fe 100644 --- a/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift +++ b/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift @@ -22,6 +22,11 @@ public struct KeyPairAuthenticator: AuthenticatorProtocol, Sendable { public let canPerformUserVerification: Bool = true public let canStoreCredentialSourceClientSide: Bool = true + /// The specific subset the client fully supports, in case more are added over time. + static let implementedPublicKeyCredentialParameterSubset: Set = [ + PublicKeyCredentialParameters(alg: .algES256), + ] + /// Initialize a key-pair based authenticator with a globally unique ID representing your application. /// - Note: To generate an AAGUID, run `% uuidgen` in your terminal. This value should generally not change across installations or versions of your app, and should be the same for every user. /// - Parameter attestationGloballyUniqueID: The AAGUID associated with the authenticator. @@ -34,7 +39,7 @@ public struct KeyPairAuthenticator: AuthenticatorProtocol, Sendable { ) { self.attestationGloballyUniqueID = attestationGloballyUniqueID self.attachmentModality = attachmentModality - self.supportedPublicKeyCredentialParameters = supportedPublicKeyCredentialParameters + self.supportedPublicKeyCredentialParameters = supportedPublicKeyCredentialParameters.intersection(Self.implementedPublicKeyCredentialParameterSubset) } public func generateCredentialSource( diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift index 9c271481..ad23b3c5 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift @@ -124,6 +124,9 @@ extension AuthenticatorProtocol { public func makeCredentials( with registration: AttestationRegistrationRequest ) async throws -> CredentialSource { + guard let chosenCredentialParameters = registration.publicKeyCredentialParameters.first(where: supportedPublicKeyCredentialParameters.contains(_:)) + else { throw WebAuthnError.noSupportedCredentialParameters } + throw WebAuthnError.unsupported } } diff --git a/Sources/WebAuthn/WebAuthnClient.swift b/Sources/WebAuthn/WebAuthnClient.swift new file mode 100644 index 00000000..3f4a5507 --- /dev/null +++ b/Sources/WebAuthn/WebAuthnClient.swift @@ -0,0 +1,235 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the WebAuthn Swift open source project +// +// Copyright (c) 2024 the WebAuthn Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import Crypto + +/// A client implementation capable of interfacing between an ``AuthenticatorProtocol`` authenticator and the Web Authentication API. +/// +/// - Important: Unless you specifically need to implement a custom WebAuthn client, it is vastly preferable to reach for the built-in [AuthenticationServices](https://developer.apple.com/documentation/authenticationservices) framework instead, which provides out-of-the-box support for a user's [Passkey](https://developer.apple.com/documentation/authenticationservices/public-private_key_authentication/supporting_passkeys). However, this is not always possible or preferrable to use this credential, especially when you want to implement silent account creation, and wish to build it off of WebAuthn. For those cases, `WebAuthnClient` is available. +/// +/// Registration: To create a registration credential, first ask the relying party (aka the server) for ``PublicKeyCredentialCreationOptions``, then pass those to ``createRegistrationCredential(options:minTimeout:maxTimeout:origin:supportedPublicKeyCredentialParameters:attestRegistration:)`` along with a closure that can generate credentials from configured ``AuthenticatorProtocol`` types such as ``KeyPairAuthenticator`` by passing the provided ``AttestationRegistration`` to ``AuthenticatorProtocol/makeCredentials(with:)``, making sure to persist the resulting ``AuthenticatorProtocol/CredentialSource`` in some way. Finally, pass the resulting ``RegistrationCredential`` back to the relying party to finish registration. +public struct WebAuthnClient { + public init() {} + + public func createRegistrationCredential( + options: PublicKeyCredentialCreationOptions, + /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout + minTimeout: Duration = .seconds(300), + maxTimeout: Duration = .seconds(600), + origin: String, + supportedPublicKeyCredentialParameters: Set = .supported, + attestRegistration: (_ registration: AttestationRegistrationRequest) async throws -> () + ) async throws -> RegistrationCredential { + /// Steps: https://w3c.github.io/webauthn/#sctn-createCredential + + /// Step 1. Assert: options.publicKey is present. + // Skip. + + /// Step 2. If sameOriginWithAncestors is false: + /// 1. If the relevant global object, as determined by the calling create() implementation, does not have transient activation: + /// 1. Throw a "NotAllowedError" DOMException. + /// 2. Consume user activation of the relevant global object. + // Skip. + + /// Step 3. Let pkOptions be the value of options.publicKey. + // Skip. + + /// Step 4. If pkOptions.timeout is present, check if its value lies within a reasonable range as defined by the client and if not, correct it to the closest value lying within that range. Set a timer lifetimeTimer to this adjusted value. If pkOptions.timeout is not present, then set lifetimeTimer to a client-specific default. + /// + /// See the recommended range and default for a WebAuthn ceremony timeout for guidance on deciding a reasonable range and default for pkOptions.timeout. + let proposedTimeout = options.timeout ?? minTimeout + let timeout = max(minTimeout, min(proposedTimeout, maxTimeout)) + + /// Step 5. If the length of pkOptions.user.id is not between 1 and 64 bytes (inclusive) then throw a TypeError. + guard 1...64 ~= options.user.id.count + else { throw WebAuthnError.invalidUserID } + + /// Step 6. Let callerOrigin be origin. If callerOrigin is an opaque origin, throw a "NotAllowedError" DOMException. + let callerOrigin = origin + + /// Step 7. Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then throw a "SecurityError" DOMException. + // Skip. + + /// Step 8. If pkOptions.rp.id + /// → is present + /// If pkOptions.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, throw a "SecurityError" DOMException. + /// → Is not present + /// Set pkOptions.rp.id to effectiveDomain. + // Skip. + + /// Step 11. Let credTypesAndPubKeyAlgs be a new list whose items are pairs of PublicKeyCredentialType and a COSEAlgorithmIdentifier. + var publicKeyCredentialParameters: [PublicKeyCredentialParameters] = [] + + /// Step 12. If pkOptions.pubKeyCredParams’s size + /// → is zero + /// Append the following pairs of PublicKeyCredentialType and COSEAlgorithmIdentifier values to credTypesAndPubKeyAlgs: + /// public-key and -7 ("ES256"). + /// public-key and -257 ("RS256"). + /// → is non-zero + /// For each current of pkOptions.pubKeyCredParams: + /// 1. If current.type does not contain a PublicKeyCredentialType supported by this implementation, then continue. + /// 2. Let alg be current.alg. + /// 3. Append the pair of current.type and alg to credTypesAndPubKeyAlgs. + /// If credTypesAndPubKeyAlgs is empty, throw a "NotSupportedError" DOMException. + if options.publicKeyCredentialParameters.isEmpty { + publicKeyCredentialParameters = [ + PublicKeyCredentialParameters(alg: .algES256), +// PublicKeyCredentialParameters(alg: .algRS256), + ] + } else { + for credentialParameter in options.publicKeyCredentialParameters { + guard supportedPublicKeyCredentialParameters.contains(credentialParameter) + else { continue } + publicKeyCredentialParameters.append(credentialParameter) + } + guard !publicKeyCredentialParameters.isEmpty + else { throw WebAuthnError.noSupportedCredentialParameters } + } + + /// Step 15. Let clientExtensions be a new map and let authenticatorExtensions be a new map. + // Skip. + + /// Step 16. If pkOptions.extensions is present, then for each extensionId → clientExtensionInput of pkOptions.extensions: + /// 1. If extensionId is not supported by this client platform or is not a registration extension, then continue. + /// 2. Set clientExtensions[extensionId] to clientExtensionInput. + /// 3. If extensionId is not an authenticator extension, then continue. + /// 4. Let authenticatorExtensionInput be the (CBOR) result of running extensionId’s client extension processing algorithm on clientExtensionInput. If the algorithm returned an error, continue. + /// 5. Set authenticatorExtensions[extensionId] to the base64url encoding of authenticatorExtensionInput. + // Skip. + + /// Step 17. Let collectedClientData be a new CollectedClientData instance whose fields are: + let collectedClientData = CollectedClientData( + /// type + /// The string "webauthn.create". + type: .create, + /// challenge + /// The base64url encoding of pkOptions.challenge. + challenge: options.challenge.base64URLEncodedString(), + /// origin + /// The serialization of callerOrigin. + origin: callerOrigin + /// topOrigin + /// The serialization of callerOrigin’s top-level origin if the sameOriginWithAncestors argument passed to this internal method is false, else undefined. + // Skip. + /// crossOrigin + /// The inverse of the value of the sameOriginWithAncestors argument passed to this internal method. + // Skip. + ) + + /// Step 18. Let clientDataJSON be the JSON-compatible serialization of client data constructed from collectedClientData. + let clientDataJSON = try JSONEncoder().encode(collectedClientData) + + /// Step 19. Let clientDataHash be the hash of the serialized client data represented by clientDataJSON. + let clientDataHash = SHA256.hash(data: clientDataJSON) + + /// Step 20. If options.signal is present and aborted, throw the options.signal’s abort reason. + // Skip. + + /// Step 21. Let issuedRequests be a new ordered set. + // Skip. + + /// Step 22. Let authenticators represent a value which at any given instant is a set of client platform-specific handles, where each item identifies an authenticator presently available on this client platform at that instant. + // Skip. + + /// Step 23. Consider the value of hints and craft the user interface accordingly, as the user-agent sees fit. + // Skip. + + /// Step 24. Start lifetimeTimer. + // Skip. + + /// Step 25. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: + // Skip. + + throw WebAuthnError.unsupported + } +} + +// MARK: Convenience Registration and Authentication + +extension WebAuthnClient { + @inlinable + public func createRegistrationCredential( + options: PublicKeyCredentialCreationOptions, + /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout + minTimeout: Duration = .seconds(300), + maxTimeout: Duration = .seconds(600), + origin: String, + supportedPublicKeyCredentialParameters: Set = .supported, + authenticator: Authenticator + ) async throws -> (registrationCredential: RegistrationCredential, credentialSource: Authenticator.CredentialSource) { + var credentialSource: Authenticator.CredentialSource? + let registrationCredential = try await createRegistrationCredential( + options: options, + minTimeout: minTimeout, + maxTimeout: maxTimeout, + origin: origin, + supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters + ) { registration in + credentialSource = try await authenticator.makeCredentials(with: registration) + } + + guard let credentialSource + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } + + return (registrationCredential, credentialSource) + } + + @inlinable + public func createRegistrationCredential( + options: PublicKeyCredentialCreationOptions, + /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout + minTimeout: Duration = .seconds(300), + maxTimeout: Duration = .seconds(600), + origin: String, + supportedPublicKeyCredentialParameters: Set = .supported, + authenticators: repeat each Authenticator + ) async throws -> ( + registrationCredential: RegistrationCredential, + credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>) + ) { + /// Wrapper function since `repeat` doesn't currently support complex expressions + @Sendable func register( + authenticator: LocalAuthenticator, + registration: AttestationRegistrationRequest + ) -> Task { + Task { try await authenticator.makeCredentials(with: registration) } + } + + var credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>)? + let registrationCredential = try await createRegistrationCredential( + options: options, + minTimeout: minTimeout, + maxTimeout: maxTimeout, + origin: origin, + supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters + ) { registration in + /// Run each authenticator in parallel as child tasks, so we can automatically propagate cancellation to each of them should it occur. + let tasks = (repeat register( + authenticator: each authenticators, + registration: registration + )) + await withTaskCancellationHandler { + credentialSources = (repeat await (each tasks).result) + } onCancel: { + repeat (each tasks).cancel() + } + } + + guard let credentialSources + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } + + return (registrationCredential, credentialSources) + } +} diff --git a/Sources/WebAuthn/WebAuthnError.swift b/Sources/WebAuthn/WebAuthnError.swift index b91ad8dd..24b83f62 100644 --- a/Sources/WebAuthn/WebAuthnError.swift +++ b/Sources/WebAuthn/WebAuthnError.swift @@ -66,6 +66,10 @@ public struct WebAuthnError: Error, Hashable, Sendable { case invalidExponent case unsupportedCOSEAlgorithmForRSAPublicKey case unsupported + + // MARK: WebAuthnClient + case noSupportedCredentialParameters + case missingCredentialSourceDespiteSuccess // MARK: Authenticator case unsupportedCredentialPublicKeyType @@ -130,6 +134,10 @@ public struct WebAuthnError: Error, Hashable, Sendable { public static let unsupportedCOSEAlgorithmForRSAPublicKey = Self(reason: .unsupportedCOSEAlgorithmForRSAPublicKey) public static let unsupported = Self(reason: .unsupported) + // MARK: WebAuthnClient + public static let noSupportedCredentialParameters = Self(reason: .noSupportedCredentialParameters) + public static let missingCredentialSourceDespiteSuccess = Self(reason: .missingCredentialSourceDespiteSuccess) + // MARK: Authenticator public static let unsupportedCredentialPublicKeyType = Self(reason: .unsupportedCredentialPublicKeyType) public static let authorizationGestureNotAllowed = Self(reason: .authorizationGestureNotAllowed) diff --git a/Sources/WebAuthn/WebAuthnManager.swift b/Sources/WebAuthn/WebAuthnManager.swift index 4ac0c226..004f010a 100644 --- a/Sources/WebAuthn/WebAuthnManager.swift +++ b/Sources/WebAuthn/WebAuthnManager.swift @@ -14,7 +14,7 @@ import Foundation -/// Main entrypoint for WebAuthn operations. +/// Main entrypoint for WebAuthn relying party (aka server-based) operations. /// /// Use this struct to perform registration and authentication ceremonies. /// From 179e0e3d9104ec24b2901041953c9fed6e6a0bbe Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Tue, 13 Feb 2024 03:33:07 -0800 Subject: [PATCH 11/29] Added basic key management and signing procedures --- .../Authenticators/KeyPairAuthenticator.swift | 68 ++++++++- .../Protocol/AuthenticatorProtocol.swift | 131 +++++++++++++++++- Sources/WebAuthn/WebAuthnClient.swift | 91 +++++++++++- 3 files changed, 281 insertions(+), 9 deletions(-) diff --git a/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift b/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift index 8ccf92fe..cd622aa6 100644 --- a/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift +++ b/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import Foundation -import Crypto +@preconcurrency import Crypto public struct KeyPairAuthenticator: AuthenticatorProtocol, Sendable { public let attestationGloballyUniqueID: AAGUID @@ -25,8 +25,21 @@ public struct KeyPairAuthenticator: AuthenticatorProtocol, Sendable { /// The specific subset the client fully supports, in case more are added over time. static let implementedPublicKeyCredentialParameterSubset: Set = [ PublicKeyCredentialParameters(alg: .algES256), + PublicKeyCredentialParameters(alg: .algES384), + PublicKeyCredentialParameters(alg: .algES512), ] + /// Generate credentials for the full subset the implementation supports. + /// + /// This list must match those supported in ``KeyPairAuthenticator/implementedPublicKeyCredentialParameterSubset``. + static func generateCredentialSourceKey(for chosenCredentialParameters: PublicKeyCredentialParameters) -> CredentialSource.Key { + switch chosenCredentialParameters.alg { + case .algES256: .es256(P256.Signing.PrivateKey(compactRepresentable: false)) + case .algES384: .es384(P384.Signing.PrivateKey(compactRepresentable: false)) + case .algES512: .es521(P521.Signing.PrivateKey(compactRepresentable: false)) + } + } + /// Initialize a key-pair based authenticator with a globally unique ID representing your application. /// - Note: To generate an AAGUID, run `% uuidgen` in your terminal. This value should generally not change across installations or versions of your app, and should be the same for every user. /// - Parameter attestationGloballyUniqueID: The AAGUID associated with the authenticator. @@ -48,7 +61,13 @@ public struct KeyPairAuthenticator: AuthenticatorProtocol, Sendable { relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID, userHandle: PublicKeyCredentialUserEntity.ID ) async throws -> CredentialSource { - throw WebAuthnError.unsupported + CredentialSource( + id: UUID(), + key: Self.generateCredentialSourceKey(for: credentialParameters), + relyingPartyID: relyingPartyID, + userHandle: userHandle, + counter: 0 + ) } public func filteredCredentialDescriptors( @@ -72,17 +91,46 @@ public struct KeyPairAuthenticator: AuthenticatorProtocol, Sendable { extension KeyPairAuthenticator { public struct CredentialSource: AuthenticatorCredentialSourceProtocol, Sendable { + public enum Key: Sendable { + case es256(P256.Signing.PrivateKey) + case es384(P384.Signing.PrivateKey) + case es521(P521.Signing.PrivateKey) + } + public var id: UUID + public var key: Key public var relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID public var userHandle: PublicKeyCredentialUserEntity.ID public var counter: UInt32 public var credentialParameters: PublicKeyCredentialParameters { - PublicKeyCredentialParameters(alg: .algES256) + switch key { + case .es256: PublicKeyCredentialParameters(alg: .algES256) + case .es384: PublicKeyCredentialParameters(alg: .algES384) + case .es521: PublicKeyCredentialParameters(alg: .algES512) + } } public var rawKeyData: Data { - Data() + switch key { + case .es256(let privateKey): privateKey.rawRepresentation + case .es384(let privateKey): privateKey.rawRepresentation + case .es521(let privateKey): privateKey.rawRepresentation + } + } + + public init( + id: ID, + key: Key, + relyingPartyID: PublicKeyCredentialRelyingPartyEntity.ID, + userHandle: PublicKeyCredentialUserEntity.ID, + counter: UInt32 + ) { + self.id = id + self.key = key + self.relyingPartyID = relyingPartyID + self.userHandle = userHandle + self.counter = 0 } public init( @@ -97,6 +145,11 @@ extension KeyPairAuthenticator { else { throw WebAuthnError.unsupportedCredentialPublicKeyType } self.id = id + switch credentialParameters.alg { + case .algES256: key = .es256(try P256.Signing.PrivateKey(rawRepresentation: rawKeyData)) + case .algES384: key = .es384(try P384.Signing.PrivateKey(rawRepresentation: rawKeyData)) + case .algES512: key = .es521(try P521.Signing.PrivateKey(rawRepresentation: rawKeyData)) + } self.relyingPartyID = relyingPartyID self.userHandle = userHandle self.counter = counter @@ -106,7 +159,12 @@ extension KeyPairAuthenticator { authenticatorData: [UInt8], clientDataHash: SHA256Digest ) throws -> [UInt8] { - throw WebAuthnError.unsupported + let digest = authenticatorData + clientDataHash + return switch key { + case .es256(let privateKey): Array(try privateKey.signature(for: digest).derRepresentation) + case .es384(let privateKey): Array(try privateKey.signature(for: digest).derRepresentation) + case .es521(let privateKey): Array(try privateKey.signature(for: digest).derRepresentation) + } } } } diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift index ad23b3c5..2c71275d 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift @@ -61,6 +61,8 @@ public protocol AuthenticatorProtocol { /// Make credentials for the specified registration request, returning the credential source that the caller should store for subsequent authentication. /// /// - Important: Depending on the authenticator being used, the credential source may contain private keys, and must be stored sequirely, such as in the user's Keychain, or in a Hardware Security Module appropriate with the level of security you wish to secure your user's account with. + /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §5.1.3. Create a New Credential - PublicKeyCredential’s Create(origin, options, sameOriginWithAncestors) Method, Step 25.]( https://w3c.github.io/webauthn/#CreateCred-async-loop) + /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §6.3.2. The authenticatorMakeCredential Operation](https://w3c.github.io/webauthn/#sctn-op-make-cred) func makeCredentials(with registration: AttestationRegistrationRequest) async throws -> CredentialSource /// Filter the provided credential descriptors to determine which, if any, should be handled by this authenticator. @@ -124,9 +126,136 @@ extension AuthenticatorProtocol { public func makeCredentials( with registration: AttestationRegistrationRequest ) async throws -> CredentialSource { + /// See [WebAuthn Level 3 Editor's Draft §5.1.3. Create a New Credential - PublicKeyCredential’s Create(origin, options, sameOriginWithAncestors) Method, Step 25.]( https://w3c.github.io/webauthn/#CreateCred-async-loop) + /// Step 1. This authenticator is now the candidate authenticator. + /// Step 2. If pkOptions.authenticatorSelection is present: + /// 1. If pkOptions.authenticatorSelection.authenticatorAttachment is present and its value is not equal to authenticator’s authenticator attachment modality, continue. + /// 2. If pkOptions.authenticatorSelection.residentKey + /// → is present and set to required + /// If the authenticator is not capable of storing a client-side discoverable public key credential source, continue. + /// → is present and set to preferred or discouraged + /// No effect. + /// → is not present + /// if pkOptions.authenticatorSelection.requireResidentKey is set to true and the authenticator is not capable of storing a client-side discoverable public key credential source, continue. + /// 6. If pkOptions.authenticatorSelection.userVerification is set to required and the authenticator is not capable of performing user verification, continue. + // Skip. + + /// Step 3. Let requireResidentKey be the effective resident key requirement for credential creation, a Boolean value, as follows: + /// If pkOptions.authenticatorSelection.residentKey + /// → is present and set to required + /// Let requireResidentKey be true. + /// → is present and set to preferred + /// If the authenticator + /// → is capable of client-side credential storage modality + /// Let requireResidentKey be true. + /// → is not capable of client-side credential storage modality, or if the client cannot determine authenticator capability, + /// Let requireResidentKey be false. + /// → is present and set to discouraged + /// Let requireResidentKey be false. + /// → is not present + /// Let requireResidentKey be the value of pkOptions.authenticatorSelection.requireResidentKey. + let requiresClientSideKeyStorage = false + + /// Step 10. Let userVerification be the effective user verification requirement for credential creation, a Boolean value, as follows. If pkOptions.authenticatorSelection.userVerification + /// → is set to required + /// Let userVerification be true. + /// → is set to preferred + /// If the authenticator + /// → is capable of user verification + /// Let userVerification be true. + /// → is not capable of user verification + /// Let userVerification be false. + /// → is set to discouraged + /// Let userVerification be false. + let shouldPerformUserVerification = false + + /// Step 16. Let enterpriseAttestationPossible be a Boolean value, as follows. If pkOptions.attestation + /// → is set to enterprise + /// Let enterpriseAttestationPossible be true if the user agent wishes to support enterprise attestation for pkOptions.rp.id (see Step 8, above). Otherwise false. + /// → otherwise + /// Let enterpriseAttestationPossible be false. + let isEnterpriseAttestationPossible = false + + /// Step 19. Let attestationFormats be a list of strings, initialized to the value of pkOptions.attestationFormats. + /// Step 20. If pkOptions.attestation + /// → is set to none + /// Set attestationFormats be the single-element list containing the string “none” + guard case .none = registration.options.attestation else { throw WebAuthnError.attestationFormatNotSupported } + + /// Step 22. Let excludeCredentialDescriptorList be a new list. + /// Step 23. For each credential descriptor C in pkOptions.excludeCredentials: + /// 1. If C.transports is not empty, and authenticator is connected over a transport not mentioned in C.transports, the client MAY continue. + /// 2. Otherwise, Append C to excludeCredentialDescriptorList. + /// 3. Invoke the authenticatorMakeCredential operation on authenticator with clientDataHash, pkOptions.rp, pkOptions.user, requireResidentKey, userVerification, credTypesAndPubKeyAlgs, excludeCredentialDescriptorList, enterpriseAttestationPossible, attestationFormats, and authenticatorExtensions as parameters. + /// Step 24. Append authenticator to issuedRequests. + + /// See [WebAuthn Level 3 Editor's Draft §6.3.2. The authenticatorMakeCredential Operation](https://w3c.github.io/webauthn/#sctn-op-make-cred) + /// Step 1. Check if all the supplied parameters are syntactically well-formed and of the correct length. If not, return an error code equivalent to "UnknownError" and terminate the operation. + /// Step 2. Check if at least one of the specified combinations of PublicKeyCredentialType and cryptographic parameters in credTypesAndPubKeyAlgs is supported. If not, return an error code equivalent to "NotSupportedError" and terminate the operation. guard let chosenCredentialParameters = registration.publicKeyCredentialParameters.first(where: supportedPublicKeyCredentialParameters.contains(_:)) else { throw WebAuthnError.noSupportedCredentialParameters } - throw WebAuthnError.unsupported + /// Step 3. For each descriptor of excludeCredentialDescriptorList: + /// 1. If looking up descriptor.id in this authenticator returns non-null, and the returned item's RP ID and type match rpEntity.id and excludeCredentialDescriptorList.type respectively, then collect an authorization gesture confirming user consent for creating a new credential. The authorization gesture MUST include a test of user presence. If the user + /// → confirms consent to create a new credential + /// return an error code equivalent to "InvalidStateError" and terminate the operation. + /// → does not consent to create a new credential + /// return an error code equivalent to "NotAllowedError" and terminate the operation. + /// NOTE: The purpose of this authorization gesture is not to proceed with creating a credential, but for privacy reasons to authorize disclosure of the fact that descriptor.id is bound to this authenticator. If the user consents, the client and Relying Party can detect this and guide the user to use a different authenticator. If the user does not consent, the authenticator does not reveal that descriptor.id is bound to it, and responds as if the user simply declined consent to create a credential. + /// Step 4. If requireResidentKey is true and the authenticator cannot store a client-side discoverable public key credential source, return an error code equivalent to "ConstraintError" and terminate the operation. + /// Step 5. If requireUserVerification is true and the authenticator cannot perform user verification, return an error code equivalent to "ConstraintError" and terminate the operation. + /// Step 6. Collect an authorization gesture confirming user consent for creating a new credential. The prompt for the authorization gesture is shown by the authenticator if it has its own output capability, or by the user agent otherwise. The prompt SHOULD display rpEntity.id, rpEntity.name, userEntity.name and userEntity.displayName, if possible. + /// → If requireUserVerification is true, the authorization gesture MUST include user verification. + /// → If requireUserPresence is true, the authorization gesture MUST include a test of user presence. + /// → If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. + /// Step 7. Once the authorization gesture has been completed and user consent has been obtained, generate a new credential object: + /// 1. Let (publicKey, privateKey) be a new pair of cryptographic keys using the combination of PublicKeyCredentialType and cryptographic parameters represented by the first item in credTypesAndPubKeyAlgs that is supported by this authenticator. + /// 2. Let userHandle be userEntity.id. + /// 3. Let credentialSource be a new public key credential source with the fields: + /// type + /// public-key. + /// privateKey + /// privateKey + /// rpId + /// rpEntity.id + /// userHandle + /// userHandle + /// otherUI + /// Any other information the authenticator chooses to include. + /// 4. If requireResidentKey is true or the authenticator chooses to create a client-side discoverable public key credential source: + /// 1. Let credentialId be a new credential id. + /// 2. Set credentialSource.id to credentialId. + /// 3. Let credentials be this authenticator’s credentials map. + /// 4. Set credentials[(rpEntity.id, userHandle)] to credentialSource. + /// 5. Otherwise: + /// Let credentialId be the result of serializing and encrypting credentialSource so that only this authenticator can decrypt it. + let credentialSource = try await generateCredentialSource( + requiresClientSideKeyStorage: requiresClientSideKeyStorage, credentialParameters: chosenCredentialParameters, + relyingPartyID: registration.options.relyingParty.id, userHandle: registration.options.user.id + ) + + /// Step 8. If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. + /// Step 9. Let processedExtensions be the result of authenticator extension processing for each supported extension identifier → authenticator extension input in extensions. + /// Step 10. If the authenticator: + /// → is a U2F device + /// let the signature counter value for the new credential be zero. (U2F devices may support signature counters but do not return a counter when making a credential. See [FIDO-U2F-Message-Formats].) + /// → supports a global signature counter + /// Use the global signature counter's actual value when generating authenticator data. + /// → supports a per credential signature counter + /// allocate the counter, associate it with the new credential, and initialize the counter value as zero. + /// → does not support a signature counter + /// let the signature counter value for the new credential be constant at zero. + /// Step 15. Let attestedCredentialData be the attested credential data byte array including the credentialId and publicKey. + /// Step 16. Let attestationFormat be the first supported attestation statement format identifier from attestationFormats, taking into account enterpriseAttestationPossible. If attestationFormats contains no supported value, then let attestationFormat be the attestation statement format identifier most preferred by this authenticator. + /// Step 17. Let authenticatorData be the byte array specified in § 6.1 Authenticator Data, including attestedCredentialData as the attestedCredentialData and processedExtensions, if any, as the extensions. + /// Step 18. Create an attestation object for the new credential using the procedure specified in § 6.5.4 Generating an Attestation Object, the attestation statement format attestationFormat, and the values authenticatorData and hash, as well as taking into account the value of enterpriseAttestationPossible. For more details on attestation, see § 6.5 Attestation. + /// On successful completion of this operation, the authenticator returns the attestation object to the client. +// try await registration.attemptRegistration.submitAttestationObject( +// attestationFormat: <#T##AttestationFormat#>, +// authenticatorData: <#T##AuthenticatorData#>, +// attestationStatement: <#T##CBOR#> +// ) + + return credentialSource } } diff --git a/Sources/WebAuthn/WebAuthnClient.swift b/Sources/WebAuthn/WebAuthnClient.swift index 3f4a5507..5be9f943 100644 --- a/Sources/WebAuthn/WebAuthnClient.swift +++ b/Sources/WebAuthn/WebAuthnClient.swift @@ -150,9 +150,94 @@ public struct WebAuthnClient { // Skip. /// Step 25. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: - // Skip. - - throw WebAuthnError.unsupported + do { + /// → If lifetimeTimer expires, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. + /// → If the user exercises a user agent user-interface option to cancel the process, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Throw a "NotAllowedError" DOMException. + /// → If options.signal is present and aborted, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Then throw the options.signal’s abort reason. + /// → If an authenticator becomes available on this client device, + /// See ``KeyPairAuthenticator/makeCredentials(with:)`` for full implementation + /// → If an authenticator ceases to be available on this client device, + /// Remove authenticator from issuedRequests. + /// → If any authenticator returns a status indicating that the user cancelled the operation, + /// 1. Remove authenticator from issuedRequests. + /// 2. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. + /// NOTE: Authenticators may return an indication of "the user cancelled the entire operation". How a user agent manifests this state to users is unspecified. + /// → If any authenticator returns an error status equivalent to "InvalidStateError", + /// 1. Remove authenticator from issuedRequests. + /// 2. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. + /// 3. Throw an "InvalidStateError" DOMException. + /// NOTE: This error status is handled separately because the authenticator returns it only if excludeCredentialDescriptorList identifies a credential bound to the authenticator and the user has consented to the operation. Given this explicit consent, it is acceptable for this case to be distinguishable to the Relying Party. + /// → If any authenticator returns an error status not equivalent to "InvalidStateError", + /// Remove authenticator from issuedRequests. + /// NOTE: This case does not imply user consent for the operation, so details about the error are hidden from the Relying Party in order to prevent leak of potentially identifying information. See § 14.5.1 Registration Ceremony Privacy for details. + + try await attestRegistration(AttestationRegistrationRequest( + options: options, + publicKeyCredentialParameters: publicKeyCredentialParameters, + clientDataHash: clientDataHash + ) { attestationObject in + throw WebAuthnError.unsupported + }) + + /// → If any authenticator indicates success, + /// 1. Remove authenticator from issuedRequests. This authenticator is now the selected authenticator. + /// 2. Let credentialCreationData be a struct whose items are: + /// attestationObjectResult + /// whose value is the bytes returned from the successful authenticatorMakeCredential operation. + /// NOTE: this value is attObj, as defined in § 6.5.4 Generating an Attestation Object. + /// clientDataJSONResult + /// whose value is the bytes of clientDataJSON. + /// attestationConveyancePreferenceOption + /// whose value is the value of pkOptions.attestation. + /// clientExtensionResults + /// whose value is an AuthenticationExtensionsClientOutputs object containing extension identifier → client extension output entries. The entries are created by running each extension’s client extension processing algorithm to create the client extension outputs, for each client extension in pkOptions.extensions. + /// 3. Let constructCredentialAlg be an algorithm that takes a global object global, and whose steps are: + /// 1. If credentialCreationData.attestationConveyancePreferenceOption’s value is + /// → none + /// Replace potentially uniquely identifying information with non-identifying versions of the same: + /// 1. If the aaguid in the attested credential data is 16 zero bytes, credentialCreationData.attestationObjectResult.fmt is "packed", and "x5c" is absent from credentialCreationData.attestationObjectResult, then self attestation is being used and no further action is needed. + /// 2. Otherwise + /// 1. Replace the aaguid in the attested credential data with 16 zero bytes. + /// 2. Set the value of credentialCreationData.attestationObjectResult.fmt to "none", and set the value of credentialCreationData.attestationObjectResult.attStmt to be an empty CBOR map. (See § 8.7 None Attestation Statement Format and § 6.5.4 Generating an Attestation Object). + /// → indirect + /// The client MAY replace the aaguid and attestation statement with a more privacy-friendly and/or more easily verifiable version of the same data (for example, by employing an Anonymization CA). + /// → direct or enterprise + /// Convey the authenticator's AAGUID and attestation statement, unaltered, to the Relying Party. + /// 5. Let attestationObject be a new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of credentialCreationData.attestationObjectResult’s value. + /// 6. Let id be attestationObject.authData.attestedCredentialData.credentialId. + /// 7. Let pubKeyCred be a new PublicKeyCredential object associated with global whose fields are: + /// [[identifier]] + /// id + /// authenticatorAttachment + /// The AuthenticatorAttachment value matching the current authenticator attachment modality of authenticator. + /// response + /// A new AuthenticatorAttestationResponse object associated with global whose fields are: + /// clientDataJSON + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of credentialCreationData.clientDataJSONResult. + /// attestationObject + /// attestationObject + /// [[transports]] + /// A sequence of zero or more unique DOMStrings, in lexicographical order, that the authenticator is believed to support. The values SHOULD be members of AuthenticatorTransport, but client platforms MUST ignore unknown values. + /// If a user agent does not wish to divulge this information it MAY substitute an arbitrary sequence designed to preserve privacy. This sequence MUST still be valid, i.e. lexicographically sorted and free of duplicates. For example, it may use the empty sequence. Either way, in this case the user agent takes the risk that Relying Party behavior may be suboptimal. + /// If the user agent does not have any transport information, it SHOULD set this field to the empty sequence. + /// NOTE: How user agents discover transports supported by a given authenticator is outside the scope of this specification, but may include information from an attestation certificate (for example [FIDO-Transports-Ext]), metadata communicated in an authenticator protocol such as CTAP2, or special-case knowledge about a platform authenticator. + /// [[clientExtensionsResults]] + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of credentialCreationData.clientExtensionResults. + /// 8. Return pubKeyCred. + /// 4. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. + /// 5. Return constructCredentialAlg and terminate this algorithm. + + throw WebAuthnError.unsupported + } catch { + /// Step 35. Throw a "NotAllowedError" DOMException. In order to prevent information leak that could identify the user without consent, this step MUST NOT be executed before lifetimeTimer has expired. See § 14.5.1 Registration Ceremony Privacy for details. + /// During the above process, the user agent SHOULD show some UI to the user to guide them in the process of selecting and authorizing an authenticator. + + /// Propagate the error originally thrown. + throw error + } } } From 4db01cee1ab99c275b51a7abca2029ec1cd90e63 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Thu, 15 Feb 2024 02:19:40 -0800 Subject: [PATCH 12/29] Added byte encoding support to CredentialPublicKey --- .../Shared/COSE/CBOR+COSEHelpers.swift | 84 +++++++++++ .../Ceremonies/Shared/COSE/COSEKey.swift | 50 ++----- .../Shared/CredentialPublicKey.swift | 130 +++++++++++++----- .../TestModels/TestCredentialPublicKey.swift | 14 +- 4 files changed, 201 insertions(+), 77 deletions(-) create mode 100644 Sources/WebAuthn/Ceremonies/Shared/COSE/CBOR+COSEHelpers.swift diff --git a/Sources/WebAuthn/Ceremonies/Shared/COSE/CBOR+COSEHelpers.swift b/Sources/WebAuthn/Ceremonies/Shared/COSE/CBOR+COSEHelpers.swift new file mode 100644 index 00000000..e0311a3b --- /dev/null +++ b/Sources/WebAuthn/Ceremonies/Shared/COSE/CBOR+COSEHelpers.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the WebAuthn Swift open source project +// +// Copyright (c) 2022 the WebAuthn Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Foundation +import SwiftCBOR + +extension CBOR { + subscript(key: COSEKey) -> CBOR? { + get { self[.signedInt(key)] } + set { self[.signedInt(key)] = newValue } + } + + static func encodeSortedPairs(_ pairs: [(COSEKey, CBOR)], options: CBOROptions = CBOROptions()) -> [UInt8] { + encodeSortedPairs(pairs.map { (CBOR.signedInt($0), $1) }, options: options) + } +} + +extension CBOR { + static func signedInt(_ int: some SignedInteger) -> CBOR { + if int < 0 { + return .negativeInt(UInt64(abs(-1 - int))) + } else { + return .unsignedInt(UInt64(int)) + } + } + + static func signedInt(_ rawInt: T) -> CBOR where T.RawValue: SignedInteger { + .signedInt(rawInt.rawValue) + } + + static func signedInt(_ rawInt: T) -> CBOR where T.RawValue: UnsignedInteger { + .unsignedInt(UInt64(rawInt.rawValue)) + } +} + +extension CBOR { + /// Adapted from SwiftCBOR's ``CBOR/encodeMap(_:options:)`` to account for the [CTAP2 canonical CBOR encoding form](https://fidoalliance.org/specs/fido-v2.1-ps-20210615/fido-client-to-authenticator-protocol-v2.1-ps-errata-20220621.html#ctap2-canonical-cbor-encoding-form). + static func encodeSortedPairs(_ pairs: [(Key, Value)], options: CBOROptions = CBOROptions()) -> [UInt8] { + var res: [UInt8] = [] + res.reserveCapacity(1 + pairs.count * (MemoryLayout.size + MemoryLayout.size + 2)) + res = pairs.count.encode(options: options) + res[0] = res[0] | 0b101_00000 + for (k, v) in pairs { + res.append(contentsOf: k.encode(options: options)) + res.append(contentsOf: v.encode(options: options)) + } + return res + } +} + +extension UnsignedInteger { + init?(_ cbor: CBOR) { + switch cbor { + case .unsignedInt(let positiveInt): + self = Self(positiveInt) + default: return nil + } + } +} + +extension SignedInteger { + init?(_ cbor: CBOR) { + switch cbor { + case .unsignedInt(let positiveInt): + self = Self(positiveInt) + case .negativeInt(let negativeInt): + // https://github.com/unrelentingtech/SwiftCBOR#swiftcbor + // Negative integers are decoded as NegativeInt(UInt), where the actual number is -1 - i + self = -1 - Self(negativeInt) + default: return nil + } + } +} diff --git a/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEKey.swift b/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEKey.swift index 797871bf..7ddd9fa4 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEKey.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEKey.swift @@ -12,47 +12,27 @@ // //===----------------------------------------------------------------------===// -import SwiftCBOR - -enum COSEKey: Sendable { +struct COSEKey: RawRepresentable, Sendable { + var rawValue: Int + + init(rawValue: Int) { + self.rawValue = rawValue + } + // swiftlint:disable identifier_name - case kty - case alg + static let kty = COSEKey(rawValue: 1) + static let alg = COSEKey(rawValue: 3) // EC2, OKP - case crv - case x + static let crv = COSEKey(rawValue: -1) + static let x = COSEKey(rawValue: -2) // EC2 - case y + static let y = COSEKey(rawValue: -3) // RSA - case n - case e + static let n = COSEKey(rawValue: -1) + static let e = COSEKey(rawValue: -2) // swiftlint:enable identifier_name - - var cbor: CBOR { - var value: Int - switch self { - case .kty: - value = 1 - case .alg: - value = 3 - case .crv: - value = -1 - case .x: - value = -2 - case .y: - value = -3 - case .n: - value = -1 - case .e: - value = -2 - } - if value < 0 { - return .negativeInt(UInt64(abs(-1 - value))) - } else { - return .unsignedInt(UInt64(value)) - } - } } + diff --git a/Sources/WebAuthn/Ceremonies/Shared/CredentialPublicKey.swift b/Sources/WebAuthn/Ceremonies/Shared/CredentialPublicKey.swift index c19324ad..fe4fc21a 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/CredentialPublicKey.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/CredentialPublicKey.swift @@ -56,21 +56,14 @@ enum CredentialPublicKey: Sendable { return } - guard let keyTypeRaw = publicKeyObject[COSEKey.kty.cbor], - case let .unsignedInt(keyTypeInt) = keyTypeRaw, - let keyType = COSEKeyType(rawValue: keyTypeInt) else { - throw WebAuthnError.invalidKeyType - } + guard let keyType = publicKeyObject[COSEKey.kty].flatMap(UInt64.init).flatMap(COSEKeyType.init(rawValue:)) + else { throw WebAuthnError.invalidKeyType } - guard let algorithmRaw = publicKeyObject[COSEKey.alg.cbor], - case let .negativeInt(algorithmNegative) = algorithmRaw else { - throw WebAuthnError.invalidAlgorithm - } - // https://github.com/unrelentingtech/SwiftCBOR#swiftcbor - // Negative integers are decoded as NegativeInt(UInt), where the actual number is -1 - i - guard let algorithm = COSEAlgorithmIdentifier(rawValue: -1 - Int(algorithmNegative)) else { - throw WebAuthnError.unsupportedCOSEAlgorithm - } + guard let algorithmRaw = publicKeyObject[COSEKey.alg].flatMap(Int.init) + else { throw WebAuthnError.invalidAlgorithm } + + guard let algorithm = COSEAlgorithmIdentifier(rawValue: algorithmRaw) + else { throw WebAuthnError.unsupportedCOSEAlgorithm } // Currently we only support elliptic curve algorithms switch keyType { @@ -89,6 +82,14 @@ enum CredentialPublicKey: Sendable { func verify(signature: some DataProtocol, data: some DataProtocol) throws { try key.verify(signature: signature, data: data) } + + var bytes: [UInt8] { + switch self { + case .okp(let oKPPublicKey): oKPPublicKey.bytes + case .ec2(let eC2PublicKey): eC2PublicKey.bytes + case .rsa(let rSAPublicKeyData): rSAPublicKeyData.bytes + } + } } struct EC2PublicKey: PublicKey, Sendable { @@ -108,6 +109,39 @@ struct EC2PublicKey: PublicKey, Sendable { self.xCoordinate = xCoordinate self.yCoordinate = yCoordinate } + + init(_ publicKey: P256.Signing.PublicKey) { + self.algorithm = .algES256 + self.curve = .p256 + + /// Split the key like SwiftCrypto does internally: https://github.com/apple/swift-crypto/blob/606608da0875e3dee07cb37da3b38585420db111/Sources/Crypto/Signatures/ECDSA.swift.gyb#L79 + let rawRepresentation = publicKey.rawRepresentation + let half = rawRepresentation.count/2 + self.xCoordinate = Array(rawRepresentation.prefix(half)) + self.yCoordinate = Array(rawRepresentation.suffix(half)) + } + + init(_ publicKey: P384.Signing.PublicKey) { + self.algorithm = .algES384 + self.curve = .p384 + + /// Split the key like SwiftCrypto does internally: https://github.com/apple/swift-crypto/blob/606608da0875e3dee07cb37da3b38585420db111/Sources/Crypto/Signatures/ECDSA.swift.gyb#L79 + let rawRepresentation = publicKey.rawRepresentation + let half = rawRepresentation.count/2 + self.xCoordinate = Array(rawRepresentation.prefix(half)) + self.yCoordinate = Array(rawRepresentation.suffix(half)) + } + + init(_ publicKey: P521.Signing.PublicKey) { + self.algorithm = .algES512 + self.curve = .p521 + + /// Split the key like SwiftCrypto does internally: https://github.com/apple/swift-crypto/blob/606608da0875e3dee07cb37da3b38585420db111/Sources/Crypto/Signatures/ECDSA.swift.gyb#L79 + let rawRepresentation = publicKey.rawRepresentation + let half = rawRepresentation.count/2 + self.xCoordinate = Array(rawRepresentation.prefix(half)) + self.yCoordinate = Array(rawRepresentation.suffix(half)) + } init(publicKeyObject: CBOR, algorithm: COSEAlgorithmIdentifier) throws { self.algorithm = algorithm @@ -115,24 +149,32 @@ struct EC2PublicKey: PublicKey, Sendable { // Curve is key -1 - or -0 for SwiftCBOR // X Coordinate is key -2, or NegativeInt 1 for SwiftCBOR // Y Coordinate is key -3, or NegativeInt 2 for SwiftCBOR - guard let curveRaw = publicKeyObject[COSEKey.crv.cbor], - case let .unsignedInt(curve) = curveRaw, - let coseCurve = COSECurve(rawValue: curve) else { - throw WebAuthnError.invalidCurve - } + guard let coseCurve = publicKeyObject[COSEKey.crv].flatMap(UInt64.init).flatMap(COSECurve.init(rawValue:)) + else { throw WebAuthnError.invalidCurve } self.curve = coseCurve - guard let xCoordRaw = publicKeyObject[COSEKey.x.cbor], - case let .byteString(xCoordinateBytes) = xCoordRaw else { - throw WebAuthnError.invalidXCoordinate - } + guard + let xCoordRaw = publicKeyObject[COSEKey.x], + case let .byteString(xCoordinateBytes) = xCoordRaw + else { throw WebAuthnError.invalidXCoordinate } xCoordinate = xCoordinateBytes - guard let yCoordRaw = publicKeyObject[COSEKey.y.cbor], - case let .byteString(yCoordinateBytes) = yCoordRaw else { - throw WebAuthnError.invalidYCoordinate - } + + guard + let yCoordRaw = publicKeyObject[COSEKey.y], + case let .byteString(yCoordinateBytes) = yCoordRaw + else { throw WebAuthnError.invalidYCoordinate } yCoordinate = yCoordinateBytes } + + var bytes: [UInt8] { + CBOR.encodeSortedPairs([ + (COSEKey.kty, .signedInt(COSEKeyType.ellipticKey)), + (COSEKey.alg, .signedInt(algorithm)), + (COSEKey.crv, .signedInt(curve)), + (COSEKey.x, .byteString(Array(xCoordinate))), + (COSEKey.y, .byteString(Array(yCoordinate))), + ]) + } func verify(signature: some DataProtocol, data: some DataProtocol) throws { switch algorithm { @@ -171,18 +213,27 @@ struct RSAPublicKeyData: PublicKey, Sendable { init(publicKeyObject: CBOR, algorithm: COSEAlgorithmIdentifier) throws { self.algorithm = algorithm - guard let nRaw = publicKeyObject[COSEKey.n.cbor], - case let .byteString(nBytes) = nRaw else { - throw WebAuthnError.invalidModulus - } + guard + let nRaw = publicKeyObject[COSEKey.n], + case let .byteString(nBytes) = nRaw + else { throw WebAuthnError.invalidModulus } n = nBytes - guard let eRaw = publicKeyObject[COSEKey.e.cbor], - case let .byteString(eBytes) = eRaw else { - throw WebAuthnError.invalidExponent - } + guard + let eRaw = publicKeyObject[COSEKey.e], + case let .byteString(eBytes) = eRaw + else { throw WebAuthnError.invalidExponent } e = eBytes } + + var bytes: [UInt8] { + CBOR.encodeSortedPairs([ + (COSEKey.kty, .signedInt(COSEKeyType.rsaKey)), + (COSEKey.alg, .signedInt(algorithm)), + (COSEKey.n, .byteString(Array(n))), + (COSEKey.e, .byteString(Array(e))), + ]) + } func verify(signature: some DataProtocol, data: some DataProtocol) throws { throw WebAuthnError.unsupported @@ -228,6 +279,15 @@ struct OKPPublicKey: PublicKey, Sendable { } xCoordinate = xCoordinateBytes } + + var bytes: [UInt8] { + CBOR.encodeSortedPairs([ + (COSEKey.kty, .signedInt(COSEKeyType.octetKey)), + (COSEKey.alg, .signedInt(algorithm)), + (COSEKey.crv, .unsignedInt(curve)), + (COSEKey.x, .byteString(xCoordinate)), + ]) + } func verify(signature: some DataProtocol, data: some DataProtocol) throws { throw WebAuthnError.unsupported diff --git a/Tests/WebAuthnTests/Utils/TestModels/TestCredentialPublicKey.swift b/Tests/WebAuthnTests/Utils/TestModels/TestCredentialPublicKey.swift index 408e7f1d..f8932e3d 100644 --- a/Tests/WebAuthnTests/Utils/TestModels/TestCredentialPublicKey.swift +++ b/Tests/WebAuthnTests/Utils/TestModels/TestCredentialPublicKey.swift @@ -23,23 +23,23 @@ struct TestCredentialPublicKey { var yCoordinate: CBOR? var byteArrayRepresentation: [UInt8] { - var value: [CBOR: CBOR] = [:] + var value: [(COSEKey, CBOR)] = [] if let kty { - value[COSEKey.kty.cbor] = kty + value.append((COSEKey.kty, kty)) } if let alg { - value[COSEKey.alg.cbor] = alg + value.append((COSEKey.alg, alg)) } if let crv { - value[COSEKey.crv.cbor] = crv + value.append((COSEKey.crv, crv)) } if let xCoordinate { - value[COSEKey.x.cbor] = xCoordinate + value.append((COSEKey.x, xCoordinate)) } if let yCoordinate { - value[COSEKey.y.cbor] = yCoordinate + value.append((COSEKey.y, yCoordinate)) } - return CBOR.map(value).encode() + return CBOR.encodeSortedPairs(value) } } From ff2fcaaa7abb6852e12c6dc6a99f7617b56567b8 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sun, 25 Feb 2024 01:08:18 -0800 Subject: [PATCH 13/29] Added a typed initializer to AuthenticatorData --- .../Ceremonies/Shared/AuthenticatorData.swift | 17 +++++++++++++++++ .../Ceremonies/Shared/AuthenticatorFlags.swift | 12 ++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift index 47564e10..97cef933 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift @@ -25,6 +25,23 @@ public struct AuthenticatorData: Equatable, Sendable { /// For attestation signatures this value will be set. For assertion signatures not. let attestedData: AttestedCredentialData? let extData: [UInt8]? + + init( + relyingPartyIDHash: SHA256Digest, + flags: AuthenticatorFlags, + counter: UInt32, + attestedData: AttestedCredentialData? = nil, + extData: [UInt8]? = nil + ) { + self.relyingPartyIDHash = Array(relyingPartyIDHash) + var flags = flags + flags.attestedCredentialData = attestedData != nil + flags.extensionDataIncluded = extData != nil + self.flags = flags + self.counter = counter + self.attestedData = attestedData + self.extData = extData + } } extension AuthenticatorData { diff --git a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift index 09d55ea8..c3b96ffa 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift @@ -33,12 +33,12 @@ public struct AuthenticatorFlags: Equatable, Sendable { case extensionDataIncluded = 7 } - let userPresent: Bool - let userVerified: Bool - let isBackupEligible: Bool - let isCurrentlyBackedUp: Bool - let attestedCredentialData: Bool - let extensionDataIncluded: Bool + var userPresent: Bool + var userVerified: Bool + var isBackupEligible: Bool + var isCurrentlyBackedUp: Bool + var attestedCredentialData: Bool + var extensionDataIncluded: Bool var deviceType: VerifiedAuthentication.CredentialDeviceType { isBackupEligible ? .multiDevice : .singleDevice From b1c51af3c9e55e973653bf30ab132eb18ef2e95c Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 16 Feb 2024 04:55:03 -0800 Subject: [PATCH 14/29] Implemented credential generation procedure --- .../Authenticators/KeyPairAuthenticator.swift | 8 ++ ...uthenticatorCredentialSourceProtocol.swift | 2 + .../Protocol/AuthenticatorProtocol.swift | 75 +++++++++++++++++-- .../Registration/AttestationObject.swift | 1 + .../Shared/AuthenticatorFlags.swift | 12 +-- .../Shared/CredentialPublicKey.swift | 4 +- 6 files changed, 88 insertions(+), 14 deletions(-) diff --git a/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift b/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift index cd622aa6..6c6f04c7 100644 --- a/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift +++ b/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift @@ -119,6 +119,14 @@ extension KeyPairAuthenticator { } } + public var publicKey: PublicKey { + switch key { + case .es256(let privateKey): EC2PublicKey(privateKey.publicKey) + case .es384(let privateKey): EC2PublicKey(privateKey.publicKey) + case .es521(let privateKey): EC2PublicKey(privateKey.publicKey) + } + } + public init( id: ID, key: Key, diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift index efa858f9..5421df84 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceProtocol.swift @@ -23,6 +23,8 @@ public protocol AuthenticatorCredentialSourceProtocol: Sendable, Identifiable wh var userHandle: PublicKeyCredentialUserEntity.ID { get } var counter: UInt32 { get } + var publicKey: PublicKey { get } + func signAssertion( authenticatorData: [UInt8], clientDataHash: SHA256Digest diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift index 2c71275d..35ab68b5 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift @@ -167,30 +167,50 @@ extension AuthenticatorProtocol { /// Let userVerification be false. /// → is set to discouraged /// Let userVerification be false. - let shouldPerformUserVerification = false +// let shouldPerformUserVerification = false /// Step 16. Let enterpriseAttestationPossible be a Boolean value, as follows. If pkOptions.attestation /// → is set to enterprise /// Let enterpriseAttestationPossible be true if the user agent wishes to support enterprise attestation for pkOptions.rp.id (see Step 8, above). Otherwise false. /// → otherwise /// Let enterpriseAttestationPossible be false. - let isEnterpriseAttestationPossible = false +// let isEnterpriseAttestationPossible = false /// Step 19. Let attestationFormats be a list of strings, initialized to the value of pkOptions.attestationFormats. +// let attestationFormats: [AttestationFormat] = [] + /// Step 20. If pkOptions.attestation /// → is set to none /// Set attestationFormats be the single-element list containing the string “none” guard case .none = registration.options.attestation else { throw WebAuthnError.attestationFormatNotSupported } /// Step 22. Let excludeCredentialDescriptorList be a new list. +// let excludeCredentialDescriptorList: [PublicKeyCredentialDescriptor] = [] /// Step 23. For each credential descriptor C in pkOptions.excludeCredentials: /// 1. If C.transports is not empty, and authenticator is connected over a transport not mentioned in C.transports, the client MAY continue. /// 2. Otherwise, Append C to excludeCredentialDescriptorList. + // Skip. + /// 3. Invoke the authenticatorMakeCredential operation on authenticator with clientDataHash, pkOptions.rp, pkOptions.user, requireResidentKey, userVerification, credTypesAndPubKeyAlgs, excludeCredentialDescriptorList, enterpriseAttestationPossible, attestationFormats, and authenticatorExtensions as parameters. + /* + registration.clientDataHash; + registration.options.relyingParty + registration.options.user + requiresResidentKey + shouldPerformUserVerification + registration.publicKeyCredentialParameters + excludeCredentialDescriptorList + isEnterpriseAttestationPossible + attestationFormats + */ + /// Step 24. Append authenticator to issuedRequests. + // Skip. /// See [WebAuthn Level 3 Editor's Draft §6.3.2. The authenticatorMakeCredential Operation](https://w3c.github.io/webauthn/#sctn-op-make-cred) /// Step 1. Check if all the supplied parameters are syntactically well-formed and of the correct length. If not, return an error code equivalent to "UnknownError" and terminate the operation. + // Skip. + /// Step 2. Check if at least one of the specified combinations of PublicKeyCredentialType and cryptographic parameters in credTypesAndPubKeyAlgs is supported. If not, return an error code equivalent to "NotSupportedError" and terminate the operation. guard let chosenCredentialParameters = registration.publicKeyCredentialParameters.first(where: supportedPublicKeyCredentialParameters.contains(_:)) else { throw WebAuthnError.noSupportedCredentialParameters } @@ -202,12 +222,20 @@ extension AuthenticatorProtocol { /// → does not consent to create a new credential /// return an error code equivalent to "NotAllowedError" and terminate the operation. /// NOTE: The purpose of this authorization gesture is not to proceed with creating a credential, but for privacy reasons to authorize disclosure of the fact that descriptor.id is bound to this authenticator. If the user consents, the client and Relying Party can detect this and guide the user to use a different authenticator. If the user does not consent, the authenticator does not reveal that descriptor.id is bound to it, and responds as if the user simply declined consent to create a credential. + // Skip. + /// Step 4. If requireResidentKey is true and the authenticator cannot store a client-side discoverable public key credential source, return an error code equivalent to "ConstraintError" and terminate the operation. + // Skip. + /// Step 5. If requireUserVerification is true and the authenticator cannot perform user verification, return an error code equivalent to "ConstraintError" and terminate the operation. + // Skip. + /// Step 6. Collect an authorization gesture confirming user consent for creating a new credential. The prompt for the authorization gesture is shown by the authenticator if it has its own output capability, or by the user agent otherwise. The prompt SHOULD display rpEntity.id, rpEntity.name, userEntity.name and userEntity.displayName, if possible. /// → If requireUserVerification is true, the authorization gesture MUST include user verification. /// → If requireUserPresence is true, the authorization gesture MUST include a test of user presence. /// → If the user does not consent or if user verification fails, return an error code equivalent to "NotAllowedError" and terminate the operation. + // Skip. + /// Step 7. Once the authorization gesture has been completed and user consent has been obtained, generate a new credential object: /// 1. Let (publicKey, privateKey) be a new pair of cryptographic keys using the combination of PublicKeyCredentialType and cryptographic parameters represented by the first item in credTypesAndPubKeyAlgs that is supported by this authenticator. /// 2. Let userHandle be userEntity.id. @@ -235,7 +263,11 @@ extension AuthenticatorProtocol { ) /// Step 8. If any error occurred while creating the new credential object, return an error code equivalent to "UnknownError" and terminate the operation. + // Skip. + /// Step 9. Let processedExtensions be the result of authenticator extension processing for each supported extension identifier → authenticator extension input in extensions. + // Skip. + /// Step 10. If the authenticator: /// → is a U2F device /// let the signature counter value for the new credential be zero. (U2F devices may support signature counters but do not return a counter when making a credential. See [FIDO-U2F-Message-Formats].) @@ -245,16 +277,45 @@ extension AuthenticatorProtocol { /// allocate the counter, associate it with the new credential, and initialize the counter value as zero. /// → does not support a signature counter /// let the signature counter value for the new credential be constant at zero. + let counter: UInt32 = credentialSource.counter + /// Step 15. Let attestedCredentialData be the attested credential data byte array including the credentialId and publicKey. + let attestedCredentialData = AttestedCredentialData( + authenticatorAttestationGUID: attestationGloballyUniqueID, + credentialID: credentialSource.id.bytes, + publicKey: credentialSource.publicKey.bytes + ) + /// Step 16. Let attestationFormat be the first supported attestation statement format identifier from attestationFormats, taking into account enterpriseAttestationPossible. If attestationFormats contains no supported value, then let attestationFormat be the attestation statement format identifier most preferred by this authenticator. + let attestationFormat = preferredAttestationFormat(from: [.none]) + /// Step 17. Let authenticatorData be the byte array specified in § 6.1 Authenticator Data, including attestedCredentialData as the attestedCredentialData and processedExtensions, if any, as the extensions. + let authenticatorData = AuthenticatorData( + relyingPartyIDHash: SHA256.hash(data: Array(registration.options.relyingParty.id.utf8)), + flags: AuthenticatorFlags( + userPresent: true, // TODO: Make flags + userVerified: true, // TODO: Make flags + isBackupEligible: true, + isCurrentlyBackedUp: true + ), + counter: counter, + attestedData: attestedCredentialData, + extData: nil + ) + /// Step 18. Create an attestation object for the new credential using the procedure specified in § 6.5.4 Generating an Attestation Object, the attestation statement format attestationFormat, and the values authenticatorData and hash, as well as taking into account the value of enterpriseAttestationPossible. For more details on attestation, see § 6.5 Attestation. + let attestationStatement = try await signAttestationStatement( + attestationFormat: attestationFormat, + authenticatorData: authenticatorData.bytes, + clientDataHash: registration.clientDataHash + ) + /// On successful completion of this operation, the authenticator returns the attestation object to the client. -// try await registration.attemptRegistration.submitAttestationObject( -// attestationFormat: <#T##AttestationFormat#>, -// authenticatorData: <#T##AuthenticatorData#>, -// attestationStatement: <#T##CBOR#> -// ) + try await registration.attemptRegistration.submitAttestationObject( + attestationFormat: attestationFormat, + authenticatorData: authenticatorData, + attestationStatement: attestationStatement + ) return credentialSource } diff --git a/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift b/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift index ec10522b..abc84d6b 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift @@ -59,6 +59,7 @@ public struct AttestationObject: Sendable { throw WebAuthnError.relyingPartyIDHashDoesNotMatch } + // TODO: Make flag guard authenticatorData.flags.userPresent else { throw WebAuthnError.userPresentFlagNotSet } diff --git a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift index c3b96ffa..7dd9e740 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorFlags.swift @@ -33,12 +33,12 @@ public struct AuthenticatorFlags: Equatable, Sendable { case extensionDataIncluded = 7 } - var userPresent: Bool - var userVerified: Bool - var isBackupEligible: Bool - var isCurrentlyBackedUp: Bool - var attestedCredentialData: Bool - var extensionDataIncluded: Bool + var userPresent: Bool = false + var userVerified: Bool = false + var isBackupEligible: Bool = false + var isCurrentlyBackedUp: Bool = false + var attestedCredentialData: Bool = false + var extensionDataIncluded: Bool = false var deviceType: VerifiedAuthentication.CredentialDeviceType { isBackupEligible ? .multiDevice : .singleDevice diff --git a/Sources/WebAuthn/Ceremonies/Shared/CredentialPublicKey.swift b/Sources/WebAuthn/Ceremonies/Shared/CredentialPublicKey.swift index fe4fc21a..5dde863f 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/CredentialPublicKey.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/CredentialPublicKey.swift @@ -17,10 +17,12 @@ import _CryptoExtras import Foundation import SwiftCBOR -protocol PublicKey: Sendable { +public protocol PublicKey: Sendable { var algorithm: COSEAlgorithmIdentifier { get } /// Verify a signature was signed with the private key corresponding to the public key. func verify(signature: some DataProtocol, data: some DataProtocol) throws + + var bytes: [UInt8] { get } } enum CredentialPublicKey: Sendable { From 924d3fdc33d131312b9f42e4e9d3affc56b7a097 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sat, 17 Feb 2024 04:29:16 -0800 Subject: [PATCH 15/29] Added helper types for dealing with continuations that can be cancelled --- .../Helpers/CancellableContinuationTask.swift | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 Sources/WebAuthn/Helpers/CancellableContinuationTask.swift diff --git a/Sources/WebAuthn/Helpers/CancellableContinuationTask.swift b/Sources/WebAuthn/Helpers/CancellableContinuationTask.swift new file mode 100644 index 00000000..b250da86 --- /dev/null +++ b/Sources/WebAuthn/Helpers/CancellableContinuationTask.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the WebAuthn Swift open source project +// +// Copyright (c) 2023 the WebAuthn Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +/// An internal type to assist kicking off work without primarily awaiting it, instead allowing that work to call into a continuation as needed. +/// Use ``withCancellableFirstSuccessfulContinuation()`` instead of invoking this directly. +actor CancellableContinuation: Sendable { + private var bodyTask: Task? + private var continuation: CheckedContinuation? + private var isCancelled = false + + private func cancelMainTask() { + continuation?.resume(throwing: CancellationError()) + continuation = nil + bodyTask?.cancel() + isCancelled = true + } + + private func isolatedResume(returning value: T) { + continuation?.resume(returning: value) + continuation = nil + cancelMainTask() + } + + nonisolated func cancel() { + Task { await cancelMainTask() } + } + + nonisolated func resume(returning value: T) { + Task { await isolatedResume(returning: value) } + } + + /// Wrap an asynchronous closure providing a continuation for when results are ready that can be called any number of times, but also allowing the closure to be cancelled at any time, including once the first successful value is provided. + fileprivate func wrap(_ body: Body) async throws -> T { + assert(bodyTask == nil, "A CancellableContinuationTask should only be used once.") + /// Register a cancellation callback that will: a) immediately cancel the continuation if we have one, b) unset it so it doesn't get called a second time, and c) cancel the main task. + return try await withTaskCancellationHandler { + let response: T = try await withCheckedThrowingContinuation { localContinuation in + /// Synchronously a) check if we've been cancelled, stopping early, b) save the contnuation, and c) assign the task, which runs immediately. + /// This works since we are guaranteed to hear back from the cancellation handler either immediately, since Task.isCancelled is already set, or after task is set, since we are executing on the actor's executor. + guard !Task.isCancelled else { + localContinuation.resume(throwing: CancellationError()) + return + } + + self.continuation = localContinuation + self.bodyTask = Task { [unowned self] in + /// If the continuation doesn't exist at this point, it's because we've already been cancelled. This is guaranteed to run after the task has been set and potentially cancelled since it also runs on the task executor. + guard let continuation = self.continuation else { return } + do { + try await body(self) + } catch { + /// If the main body fails for any reason, pass along the error. This will be a no-op if the continuation was already resumed or cancelled. + continuation.resume(throwing: error) + self.continuation = nil + } + } + } + /// Wait for the body to finish cancelling before continuing, so it doesn't run into any data races. + try? await bodyTask?.value + return response + } onCancel: { + cancel() + } + } + + /// A wrapper for the body, which will ever only be called once, in a non-escaping manner before the continuation resumes. + fileprivate struct Body: @unchecked Sendable { + var body: (_ continuation: CancellableContinuation) async throws -> () + + func callAsFunction(_ continuation: CancellableContinuation) async throws { + try await body(continuation) + } + } +} + +/// Execute an operation providing it a continuation for when results are ready that can be called any number of times, but also allowing the operation to be cancelled at any time, including once the first successful value is provided. +func withCancellableFirstSuccessfulContinuation(_ body: (_ continuation: CancellableContinuation) async throws -> ()) async throws -> T { + try await withoutActuallyEscaping(body) { escapingBody in + try await CancellableContinuation().wrap(.init { try await escapingBody($0) }) + } +} From 09e080d4e02a3b00028a851b860f46bd93c2e832 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sat, 17 Feb 2024 04:31:41 -0800 Subject: [PATCH 16/29] Updated client to properly call into registration attestation before finalizing the results and coordinating in-flight tasks --- Sources/WebAuthn/WebAuthnClient.swift | 87 +++++++++++++++++---------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/Sources/WebAuthn/WebAuthnClient.swift b/Sources/WebAuthn/WebAuthnClient.swift index 5be9f943..d79ab499 100644 --- a/Sources/WebAuthn/WebAuthnClient.swift +++ b/Sources/WebAuthn/WebAuthnClient.swift @@ -147,40 +147,57 @@ public struct WebAuthnClient { // Skip. /// Step 24. Start lifetimeTimer. - // Skip. + let timeoutTask = Task { try? await Task.sleep(for: timeout) } /// Step 25. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: do { - /// → If lifetimeTimer expires, - /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. - /// → If the user exercises a user agent user-interface option to cancel the process, - /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Throw a "NotAllowedError" DOMException. - /// → If options.signal is present and aborted, - /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Then throw the options.signal’s abort reason. - /// → If an authenticator becomes available on this client device, - /// See ``KeyPairAuthenticator/makeCredentials(with:)`` for full implementation - /// → If an authenticator ceases to be available on this client device, - /// Remove authenticator from issuedRequests. - /// → If any authenticator returns a status indicating that the user cancelled the operation, - /// 1. Remove authenticator from issuedRequests. - /// 2. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. - /// NOTE: Authenticators may return an indication of "the user cancelled the entire operation". How a user agent manifests this state to users is unspecified. - /// → If any authenticator returns an error status equivalent to "InvalidStateError", - /// 1. Remove authenticator from issuedRequests. - /// 2. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. - /// 3. Throw an "InvalidStateError" DOMException. - /// NOTE: This error status is handled separately because the authenticator returns it only if excludeCredentialDescriptorList identifies a credential bound to the authenticator and the user has consented to the operation. Given this explicit consent, it is acceptable for this case to be distinguishable to the Relying Party. - /// → If any authenticator returns an error status not equivalent to "InvalidStateError", - /// Remove authenticator from issuedRequests. - /// NOTE: This case does not imply user consent for the operation, so details about the error are hidden from the Relying Party in order to prevent leak of potentially identifying information. See § 14.5.1 Registration Ceremony Privacy for details. - - try await attestRegistration(AttestationRegistrationRequest( - options: options, - publicKeyCredentialParameters: publicKeyCredentialParameters, - clientDataHash: clientDataHash - ) { attestationObject in - throw WebAuthnError.unsupported - }) + /// Let the caller do what it needs to do to coordinate with authenticators, so long as at least one of them calls the attestation callback. + let result: AttestationObject = try await withCancellableFirstSuccessfulContinuation { [attestRegistration, publicKeyCredentialParameters] continuation in + /// → If lifetimeTimer expires, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. + Task { + /// Let the timer run in the background to cancel the continuation if it runs over. + await timeoutTask.value + continuation.cancel() // TODO: Should be a timeout error + } + + /// → If the user exercises a user agent user-interface option to cancel the process, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Throw a "NotAllowedError" DOMException. + // Implemented in catch statement below. + + /// → If options.signal is present and aborted, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Then throw the options.signal’s abort reason. + // Skip. + + /// → If an authenticator becomes available on this client device, + /// See ``KeyPairAuthenticator/makeCredentials(with:)`` for full implementation + /// → If an authenticator ceases to be available on this client device, + /// Remove authenticator from issuedRequests. + /// → If any authenticator returns a status indicating that the user cancelled the operation, + /// 1. Remove authenticator from issuedRequests. + /// 2. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. + /// NOTE: Authenticators may return an indication of "the user cancelled the entire operation". How a user agent manifests this state to users is unspecified. + // User can cancel the main task instead. + + /// → If any authenticator returns an error status equivalent to "InvalidStateError", + /// 1. Remove authenticator from issuedRequests. + /// 2. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. + /// 3. Throw an "InvalidStateError" DOMException. + /// NOTE: This error status is handled separately because the authenticator returns it only if excludeCredentialDescriptorList identifies a credential bound to the authenticator and the user has consented to the operation. Given this explicit consent, it is acceptable for this case to be distinguishable to the Relying Party. + // TODO: Need to catch this specific type of error + /// → If any authenticator returns an error status not equivalent to "InvalidStateError", + /// Remove authenticator from issuedRequests. + /// NOTE: This case does not imply user consent for the operation, so details about the error are hidden from the Relying Party in order to prevent leak of potentially identifying information. See § 14.5.1 Registration Ceremony Privacy for details. + + /// Kick off the attestation process, waiting for one to succeed before the timeout. + try await attestRegistration(AttestationRegistrationRequest( + options: options, + publicKeyCredentialParameters: publicKeyCredentialParameters, + clientDataHash: clientDataHash + ) { attestationObject in + continuation.resume(returning: attestationObject) + }) + } /// → If any authenticator indicates success, /// 1. Remove authenticator from issuedRequests. This authenticator is now the selected authenticator. @@ -234,7 +251,13 @@ public struct WebAuthnClient { } catch { /// Step 35. Throw a "NotAllowedError" DOMException. In order to prevent information leak that could identify the user without consent, this step MUST NOT be executed before lifetimeTimer has expired. See § 14.5.1 Registration Ceremony Privacy for details. /// During the above process, the user agent SHOULD show some UI to the user to guide them in the process of selecting and authorizing an authenticator. - + await withTaskCancellationHandler { + /// Make sure to wait until the timeout finishes if an error did occur. + await timeoutTask.value + } onCancel: { + /// However, if the user cancelled the process, stop the timer early. + timeoutTask.cancel() + } /// Propagate the error originally thrown. throw error } From 4c6515fca3b2805fcb51a4811f2720b991c4d2fc Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sun, 25 Feb 2024 01:09:16 -0800 Subject: [PATCH 17/29] Updated AuthenticatorData members to be mutable --- .../WebAuthn/Ceremonies/Shared/AuthenticatorData.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift index 97cef933..678203aa 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/AuthenticatorData.swift @@ -19,12 +19,12 @@ import SwiftCBOR /// Data created and/ or used by the authenticator during authentication/ registration. /// The data contains, for example, whether a user was present or verified. public struct AuthenticatorData: Equatable, Sendable { - let relyingPartyIDHash: [UInt8] - let flags: AuthenticatorFlags - let counter: UInt32 + var relyingPartyIDHash: [UInt8] + var flags: AuthenticatorFlags + var counter: UInt32 /// For attestation signatures this value will be set. For assertion signatures not. - let attestedData: AttestedCredentialData? - let extData: [UInt8]? + var attestedData: AttestedCredentialData? + var extData: [UInt8]? init( relyingPartyIDHash: SHA256Digest, From a6779d3009ffb213f76b6ef58191ec2120481106 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sun, 18 Feb 2024 02:40:27 -0800 Subject: [PATCH 18/29] Implemented remaining client registration procedure --- .../Registration/AttestationObject.swift | 39 ++++++++- .../Registration/AttestedCredentialData.swift | 6 +- .../AuthenticatorAttestationResponse.swift | 26 +----- .../Registration/RegistrationCredential.swift | 11 +++ Sources/WebAuthn/WebAuthnClient.swift | 85 ++++++++++++------- .../WebAuthnManagerIntegrationTests.swift | 4 +- .../WebAuthnManagerRegistrationTests.swift | 3 +- 7 files changed, 107 insertions(+), 67 deletions(-) diff --git a/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift b/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift index abc84d6b..a4d7ed65 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift @@ -18,10 +18,10 @@ import Crypto /// Contains the cryptographic attestation that a new key pair was created by that authenticator. public struct AttestationObject: Sendable { - let authenticatorData: AuthenticatorData - let rawAuthenticatorData: [UInt8] - let format: AttestationFormat - let attestationStatement: CBOR + var authenticatorData: AuthenticatorData + var rawAuthenticatorData: [UInt8] + var format: AttestationFormat + var attestationStatement: CBOR init( authenticatorData: AuthenticatorData, @@ -45,6 +45,37 @@ public struct AttestationObject: Sendable { self.format = format self.attestationStatement = attestationStatement } + + init(bytes: [UInt8]) throws { + guard let decodedAttestationObject = try? CBOR.decode(bytes, options: CBOROptions(maximumDepth: 16)) + else { throw WebAuthnError.invalidAttestationObject } + + guard + let authData = decodedAttestationObject["authData"], + case let .byteString(authDataBytes) = authData + else { throw WebAuthnError.invalidAuthData } + self.authenticatorData = try AuthenticatorData(bytes: authDataBytes) + self.rawAuthenticatorData = authDataBytes + + guard + let formatCBOR = decodedAttestationObject["fmt"], + case let .utf8String(format) = formatCBOR, + let attestationFormat = AttestationFormat(rawValue: format) + else { throw WebAuthnError.invalidFmt } + self.format = attestationFormat + + guard let attestationStatement = decodedAttestationObject["attStmt"] + else { throw WebAuthnError.missingAttStmt } + self.attestationStatement = attestationStatement + } + + var bytes: [UInt8] { + CBOR.encode([ + "authData": CBOR.byteString(authenticatorData.bytes), + "fmt": CBOR.utf8String(format.rawValue), + "attStmt": attestationStatement, + ]) + } func verify( relyingPartyID: String, diff --git a/Sources/WebAuthn/Ceremonies/Registration/AttestedCredentialData.swift b/Sources/WebAuthn/Ceremonies/Registration/AttestedCredentialData.swift index 7d66912e..a628ad70 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AttestedCredentialData.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AttestedCredentialData.swift @@ -14,7 +14,7 @@ // Contains the new public key created by the authenticator. public struct AttestedCredentialData: Equatable, Sendable { - let authenticatorAttestationGUID: AAGUID - let credentialID: [UInt8] - let publicKey: [UInt8] + var authenticatorAttestationGUID: AAGUID + var credentialID: [UInt8] + var publicKey: [UInt8] } diff --git a/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift b/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift index 8e5b85f4..3023278e 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift @@ -55,30 +55,6 @@ struct ParsedAuthenticatorAttestationResponse { self.clientData = clientData // Step 11. (assembling attestationObject) - let attestationObjectData = Data(rawResponse.attestationObject) - guard let decodedAttestationObject = try? CBOR.decode([UInt8](attestationObjectData), options: CBOROptions(maximumDepth: 16)) else { - throw WebAuthnError.invalidAttestationObject - } - - guard let authData = decodedAttestationObject["authData"], - case let .byteString(authDataBytes) = authData else { - throw WebAuthnError.invalidAuthData - } - guard let formatCBOR = decodedAttestationObject["fmt"], - case let .utf8String(format) = formatCBOR, - let attestationFormat = AttestationFormat(rawValue: format) else { - throw WebAuthnError.invalidFmt - } - - guard let attestationStatement = decodedAttestationObject["attStmt"] else { - throw WebAuthnError.missingAttStmt - } - - attestationObject = AttestationObject( - authenticatorData: try AuthenticatorData(bytes: authDataBytes), - rawAuthenticatorData: authDataBytes, - format: attestationFormat, - attestationStatement: attestationStatement - ) + attestationObject = try AttestationObject(bytes: rawResponse.attestationObject) } } diff --git a/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift b/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift index dbe1420c..e1f89425 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift @@ -30,6 +30,17 @@ public struct RegistrationCredential: Sendable { /// The attestation response from the authenticator. public let attestationResponse: AuthenticatorAttestationResponse + + init( + type: CredentialType = .publicKey, + id: [UInt8], + attestationResponse: AuthenticatorAttestationResponse + ) { + self.id = id.base64URLEncodedString() + self.type = type + self.rawID = id + self.attestationResponse = attestationResponse + } } extension RegistrationCredential: Decodable { diff --git a/Sources/WebAuthn/WebAuthnClient.swift b/Sources/WebAuthn/WebAuthnClient.swift index d79ab499..c08fc627 100644 --- a/Sources/WebAuthn/WebAuthnClient.swift +++ b/Sources/WebAuthn/WebAuthnClient.swift @@ -152,7 +152,7 @@ public struct WebAuthnClient { /// Step 25. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: do { /// Let the caller do what it needs to do to coordinate with authenticators, so long as at least one of them calls the attestation callback. - let result: AttestationObject = try await withCancellableFirstSuccessfulContinuation { [attestRegistration, publicKeyCredentialParameters] continuation in + var attestationObjectResult: AttestationObject = try await withCancellableFirstSuccessfulContinuation { [attestRegistration, publicKeyCredentialParameters] continuation in /// → If lifetimeTimer expires, /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Task { @@ -213,41 +213,66 @@ public struct WebAuthnClient { /// whose value is an AuthenticationExtensionsClientOutputs object containing extension identifier → client extension output entries. The entries are created by running each extension’s client extension processing algorithm to create the client extension outputs, for each client extension in pkOptions.extensions. /// 3. Let constructCredentialAlg be an algorithm that takes a global object global, and whose steps are: /// 1. If credentialCreationData.attestationConveyancePreferenceOption’s value is - /// → none - /// Replace potentially uniquely identifying information with non-identifying versions of the same: - /// 1. If the aaguid in the attested credential data is 16 zero bytes, credentialCreationData.attestationObjectResult.fmt is "packed", and "x5c" is absent from credentialCreationData.attestationObjectResult, then self attestation is being used and no further action is needed. - /// 2. Otherwise - /// 1. Replace the aaguid in the attested credential data with 16 zero bytes. - /// 2. Set the value of credentialCreationData.attestationObjectResult.fmt to "none", and set the value of credentialCreationData.attestationObjectResult.attStmt to be an empty CBOR map. (See § 8.7 None Attestation Statement Format and § 6.5.4 Generating an Attestation Object). - /// → indirect - /// The client MAY replace the aaguid and attestation statement with a more privacy-friendly and/or more easily verifiable version of the same data (for example, by employing an Anonymization CA). - /// → direct or enterprise - /// Convey the authenticator's AAGUID and attestation statement, unaltered, to the Relying Party. + switch options.attestation { + /// → none + case .none: + /// Replace potentially uniquely identifying information with non-identifying versions of the same: + /// 1. If the aaguid in the attested credential data is 16 zero bytes, credentialCreationData.attestationObjectResult.fmt is "packed", and "x5c" is absent from credentialCreationData.attestationObjectResult, then self attestation is being used and no further action is needed. + /// 2. Otherwise + if attestationObjectResult.authenticatorData.attestedData?.authenticatorAttestationGUID != .anonymous, + attestationObjectResult.format != .packed, + attestationObjectResult.attestationStatement["x5c"] == nil { + /// 1. Replace the aaguid in the attested credential data with 16 zero bytes. + attestationObjectResult.authenticatorData.attestedData?.authenticatorAttestationGUID = .anonymous + /// 2. Set the value of credentialCreationData.attestationObjectResult.fmt to "none", and set the value of credentialCreationData.attestationObjectResult.attStmt to be an empty CBOR map. (See § 8.7 None Attestation Statement Format and § 6.5.4 Generating an Attestation Object). + attestationObjectResult.format = .none + attestationObjectResult.attestationStatement = [:] + } + /// → indirect + /// The client MAY replace the aaguid and attestation statement with a more privacy-friendly and/or more easily verifiable version of the same data (for example, by employing an Anonymization CA). + /// → direct or enterprise + /// Convey the authenticator's AAGUID and attestation statement, unaltered, to the Relying Party. + } /// 5. Let attestationObject be a new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of credentialCreationData.attestationObjectResult’s value. + let attestationObject = attestationObjectResult.bytes + /// 6. Let id be attestationObject.authData.attestedCredentialData.credentialId. + guard let credentialID = attestationObjectResult.authenticatorData.attestedData?.credentialID + else { throw WebAuthnError.attestedCredentialDataMissing } + /// 7. Let pubKeyCred be a new PublicKeyCredential object associated with global whose fields are: - /// [[identifier]] - /// id - /// authenticatorAttachment - /// The AuthenticatorAttachment value matching the current authenticator attachment modality of authenticator. - /// response - /// A new AuthenticatorAttestationResponse object associated with global whose fields are: - /// clientDataJSON - /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of credentialCreationData.clientDataJSONResult. - /// attestationObject - /// attestationObject - /// [[transports]] - /// A sequence of zero or more unique DOMStrings, in lexicographical order, that the authenticator is believed to support. The values SHOULD be members of AuthenticatorTransport, but client platforms MUST ignore unknown values. - /// If a user agent does not wish to divulge this information it MAY substitute an arbitrary sequence designed to preserve privacy. This sequence MUST still be valid, i.e. lexicographically sorted and free of duplicates. For example, it may use the empty sequence. Either way, in this case the user agent takes the risk that Relying Party behavior may be suboptimal. - /// If the user agent does not have any transport information, it SHOULD set this field to the empty sequence. - /// NOTE: How user agents discover transports supported by a given authenticator is outside the scope of this specification, but may include information from an attestation certificate (for example [FIDO-Transports-Ext]), metadata communicated in an authenticator protocol such as CTAP2, or special-case knowledge about a platform authenticator. - /// [[clientExtensionsResults]] - /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of credentialCreationData.clientExtensionResults. + let publicKeyCredential = RegistrationCredential( + /// [[identifier]] + /// id + id: credentialID, + /// authenticatorAttachment + /// The AuthenticatorAttachment value matching the current authenticator attachment modality of authenticator. + /// response + /// A new AuthenticatorAttestationResponse object associated with global whose fields are: + attestationResponse: AuthenticatorAttestationResponse( + /// clientDataJSON + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of credentialCreationData.clientDataJSONResult. + clientDataJSON: Array(clientDataJSON), + /// attestationObject + /// attestationObject + attestationObject: attestationObject + /// [[transports]] + /// A sequence of zero or more unique DOMStrings, in lexicographical order, that the authenticator is believed to support. The values SHOULD be members of AuthenticatorTransport, but client platforms MUST ignore unknown values. + /// If a user agent does not wish to divulge this information it MAY substitute an arbitrary sequence designed to preserve privacy. This sequence MUST still be valid, i.e. lexicographically sorted and free of duplicates. For example, it may use the empty sequence. Either way, in this case the user agent takes the risk that Relying Party behavior may be suboptimal. + /// If the user agent does not have any transport information, it SHOULD set this field to the empty sequence. + /// NOTE: How user agents discover transports supported by a given authenticator is outside the scope of this specification, but may include information from an attestation certificate (for example [FIDO-Transports-Ext]), metadata communicated in an authenticator protocol such as CTAP2, or special-case knowledge about a platform authenticator. + ) + /// [[clientExtensionsResults]] + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of credentialCreationData.clientExtensionResults. + ) /// 8. Return pubKeyCred. + // Returned below. + /// 4. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. - /// 5. Return constructCredentialAlg and terminate this algorithm. + // Already performed. - throw WebAuthnError.unsupported + /// 5. Return constructCredentialAlg and terminate this algorithm. + return publicKeyCredential } catch { /// Step 35. Throw a "NotAllowedError" DOMException. In order to prevent information leak that could identify the user without consent, this step MUST NOT be executed before lifetimeTimer has expired. See § 14.5.1 Registration Ceremony Privacy for details. /// During the above process, the user agent SHOULD show some UI to the user to guide them in the process of selecting and authorizing an authenticator. diff --git a/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift b/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift index 5572c8e1..a453a74e 100644 --- a/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift +++ b/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift @@ -64,9 +64,7 @@ final class WebAuthnManagerIntegrationTests: XCTestCase { ).build().cborEncoded let registrationResponse = RegistrationCredential( - id: mockCredentialID.base64URLEncodedString(), - type: .publicKey, - rawID: mockCredentialID, + id: mockCredentialID, attestationResponse: AuthenticatorAttestationResponse( clientDataJSON: mockClientDataJSON.jsonBytes, attestationObject: mockAttestationObject diff --git a/Tests/WebAuthnTests/WebAuthnManagerRegistrationTests.swift b/Tests/WebAuthnTests/WebAuthnManagerRegistrationTests.swift index d26872b6..443b49c9 100644 --- a/Tests/WebAuthnTests/WebAuthnManagerRegistrationTests.swift +++ b/Tests/WebAuthnTests/WebAuthnManagerRegistrationTests.swift @@ -375,9 +375,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { try await webAuthnManager.finishRegistration( challenge: challenge, credentialCreationData: RegistrationCredential( - id: rawID.base64URLEncodedString(), type: type, - rawID: rawID, + id: rawID, attestationResponse: AuthenticatorAttestationResponse( clientDataJSON: clientDataJSON, attestationObject: attestationObject From 05cf264e3aeae56cdac4757b0fcf71d789f9f0ee Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sun, 18 Feb 2024 03:09:47 -0800 Subject: [PATCH 19/29] Updated RegistrationCredential to be Codable --- .../AuthenticatorAttestationResponse.swift | 9 ++++++++- .../Registration/RegistrationCredential.swift | 20 ++++++++++--------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift b/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift index 3023278e..30d5235f 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift @@ -30,13 +30,20 @@ public struct AuthenticatorAttestationResponse: Sendable { public let attestationObject: [UInt8] } -extension AuthenticatorAttestationResponse: Decodable { +extension AuthenticatorAttestationResponse: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) clientDataJSON = try container.decodeBytesFromURLEncodedBase64(forKey: .clientDataJSON) attestationObject = try container.decodeBytesFromURLEncodedBase64(forKey: .attestationObject) } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(clientDataJSON.base64URLEncodedString(), forKey: .clientDataJSON) + try container.encode(attestationObject.base64URLEncodedString(), forKey: .attestationObject) + } private enum CodingKeys: String, CodingKey { case clientDataJSON diff --git a/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift b/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift index e1f89425..bd6fdacd 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift @@ -43,23 +43,25 @@ public struct RegistrationCredential: Sendable { } } -extension RegistrationCredential: Decodable { +extension RegistrationCredential: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) id = try container.decode(URLEncodedBase64.self, forKey: .id) type = try container.decode(CredentialType.self, forKey: .type) - guard let rawID = try container.decode(URLEncodedBase64.self, forKey: .rawID).decodedBytes else { - throw DecodingError.dataCorruptedError( - forKey: .rawID, - in: container, - debugDescription: "Failed to decode base64url encoded rawID into bytes" - ) - } - self.rawID = rawID + rawID = try container.decodeBytesFromURLEncodedBase64(forKey: .rawID) attestationResponse = try container.decode(AuthenticatorAttestationResponse.self, forKey: .attestationResponse) } + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(rawID.base64URLEncodedString(), forKey: .rawID) + try container.encode(type, forKey: .type) + try container.encode(attestationResponse, forKey: .attestationResponse) + } + private enum CodingKeys: String, CodingKey { case id case type From 37bd51ce5d7d3c5fe6c992ba5e64eab0d749c0ec Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Wed, 21 Feb 2024 02:35:02 -0800 Subject: [PATCH 20/29] Updated PublicKeyCredentialCreationOptions to be Codable --- .../AttestationConveyancePreference.swift | 2 +- .../PublicKeyCredentialCreationOptions.swift | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/Sources/WebAuthn/Ceremonies/Registration/AttestationConveyancePreference.swift b/Sources/WebAuthn/Ceremonies/Registration/AttestationConveyancePreference.swift index 90c25e4d..897378ea 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AttestationConveyancePreference.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AttestationConveyancePreference.swift @@ -15,7 +15,7 @@ /// Options to specify the Relying Party's preference regarding attestation conveyance during credential generation. /// /// Currently only supports `none`. -public enum AttestationConveyancePreference: String, Encodable, Sendable { +public enum AttestationConveyancePreference: String, Codable, Sendable { /// Indicates the Relying Party is not interested in authenticator attestation. case none // case indirect diff --git a/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift b/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift index 6ae303a7..d5871a37 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift @@ -20,7 +20,7 @@ import Foundation /// `Encodable` byte arrays are base64url encoded. /// /// - SeeAlso: https://www.w3.org/TR/webauthn-2/#dictionary-makecredentialoptions -public struct PublicKeyCredentialCreationOptions: Encodable, Sendable { +public struct PublicKeyCredentialCreationOptions: Sendable { /// A byte array randomly generated by the Relying Party. Should be at least 16 bytes long to ensure sufficient /// entropy. /// @@ -47,6 +47,19 @@ public struct PublicKeyCredentialCreationOptions: Encodable, Sendable { /// Sets the Relying Party's preference for attestation conveyance. At the time of writing only `none` is /// supported. public let attestation: AttestationConveyancePreference +} + +extension PublicKeyCredentialCreationOptions: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + challenge = try container.decodeBytesFromURLEncodedBase64(forKey: .challenge) + user = try container.decode(PublicKeyCredentialUserEntity.self, forKey: .user) + relyingParty = try container.decode(PublicKeyCredentialRelyingPartyEntity.self, forKey: .relyingParty) + publicKeyCredentialParameters = try container.decode([PublicKeyCredentialParameters].self, forKey: .publicKeyCredentialParameters) + timeout = try container.decodeIfPresent(UInt32.self, forKey: .timeout).map { .milliseconds($0) } + attestation = try container.decode(AttestationConveyancePreference.self, forKey: .attestation) + } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) From 233870a274499ff089ffb5127f0940b8e5cf3869 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Sat, 24 Feb 2024 04:10:42 -0800 Subject: [PATCH 21/29] Added AssertionAuthenticationRequest to assist with authentication assertions --- .../AssertionAuthenticationRequest.swift | 69 +++++++++++++++++++ .../Protocol/AuthenticatorProtocol.swift | 28 ++++++++ 2 files changed, 97 insertions(+) create mode 100644 Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift diff --git a/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift b/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift new file mode 100644 index 00000000..60452880 --- /dev/null +++ b/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the WebAuthn Swift open source project +// +// Copyright (c) 2024 the WebAuthn Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import Crypto + +public struct AssertionAuthenticationRequest { + public var options: PublicKeyCredentialRequestOptions + public var clientDataHash: SHA256Digest + public var attemptAuthentication: Callback + + init( + options: PublicKeyCredentialRequestOptions, + clientDataHash: SHA256Digest, + attemptAuthentication: @escaping (_ assertionResults: Results) async throws -> () + ) { + self.options = options + self.clientDataHash = clientDataHash + self.attemptAuthentication = Callback(callback: attemptAuthentication) + } +} + +extension AssertionAuthenticationRequest { + public struct Callback { + /// The internal callback the attestation should call. + var callback: (_ assertionResults: Results) async throws -> () + + /// Submit the results of asserting a user's authentication request. + /// + /// Authenticators should call this to submit a successful authentication and cancel any other pending authenticators. + /// + /// - SeeAlso: https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object + public func submitAssertionResults( + credentialID: [UInt8], + authenticatorData: [UInt8], + signature: [UInt8], + userHandle: [UInt8]?, + authenticatorAttachment: AuthenticatorAttachment + ) async throws { + try await callback(Results( + credentialID: credentialID, + authenticatorData: authenticatorData, + signature: signature, + userHandle: userHandle, + authenticatorAttachment: authenticatorAttachment + )) + } + } +} + +extension AssertionAuthenticationRequest { + struct Results { + var credentialID: [UInt8] + var authenticatorData: [UInt8] + var signature: [UInt8] + var userHandle: [UInt8]? + var authenticatorAttachment: AuthenticatorAttachment + } +} diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift index 35ab68b5..a0236d26 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift @@ -15,6 +15,8 @@ import Crypto import SwiftCBOR +public typealias CredentialStore = [A.CredentialSource.ID : A.CredentialSource] + public protocol AuthenticatorProtocol { associatedtype CredentialSource: AuthenticatorCredentialSourceProtocol @@ -90,6 +92,21 @@ public protocol AuthenticatorProtocol { requiresUserPresence: Bool, credentialOptions: [CredentialSource] ) async throws -> CredentialSource + + /// Request that an authenticator assert one of the specified credentials. + /// + /// - Note: If the authenticator fails, other authenticators should continue until either one succeeds, or the parent task is cancelled. + /// + /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §5.1.4.2. Issuing a Credential Request to an Authenticator](https://w3c.github.io/webauthn/#sctn-issuing-cred-request-to-authenticator) + /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §6.3.3. The authenticatorGetAssertion Operation](https://w3c.github.io/webauthn/#authenticatorgetassertion) + /// - Parameters: + /// - authenticationRequest: The authentication request from the relying party. + /// - credentials: The set of credentials the authenticator should match against. + /// - Returns: An updated credential source upon successful authentication. + func assertCredentials( + authenticationRequest: AssertionAuthenticationRequest, + credentials: CredentialStore + ) async throws -> CredentialSource } // MARK: - Default Implementations @@ -320,3 +337,14 @@ extension AuthenticatorProtocol { return credentialSource } } + +// MARK: Authentication + +extension AuthenticatorProtocol { + public func assertCredentials( + authenticationRequest: AssertionAuthenticationRequest, + credentials: CredentialStore + ) async throws -> CredentialSource { + throw WebAuthnError.unsupported + } +} From 52f5a3e7978f51b688ea6def6ff1dcee9d120505 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Mon, 19 Feb 2024 01:56:47 -0800 Subject: [PATCH 22/29] Added basic outline for client authentication --- .../AssertionAuthenticationRequest.swift | 10 +- ...henticatorCredentialSourceIdentifier.swift | 2 +- Sources/WebAuthn/WebAuthnClient.swift | 107 ++++++++++++++++++ 3 files changed, 113 insertions(+), 6 deletions(-) diff --git a/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift b/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift index 60452880..f6c0eaaf 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift @@ -12,9 +12,9 @@ // //===----------------------------------------------------------------------===// -import Crypto +@preconcurrency import Crypto -public struct AssertionAuthenticationRequest { +public struct AssertionAuthenticationRequest: Sendable { public var options: PublicKeyCredentialRequestOptions public var clientDataHash: SHA256Digest public var attemptAuthentication: Callback @@ -22,7 +22,7 @@ public struct AssertionAuthenticationRequest { init( options: PublicKeyCredentialRequestOptions, clientDataHash: SHA256Digest, - attemptAuthentication: @escaping (_ assertionResults: Results) async throws -> () + attemptAuthentication: @Sendable @escaping (_ assertionResults: Results) async throws -> () ) { self.options = options self.clientDataHash = clientDataHash @@ -31,9 +31,9 @@ public struct AssertionAuthenticationRequest { } extension AssertionAuthenticationRequest { - public struct Callback { + public struct Callback: Sendable { /// The internal callback the attestation should call. - var callback: (_ assertionResults: Results) async throws -> () + var callback: @Sendable (_ assertionResults: Results) async throws -> () /// Submit the results of asserting a user's authentication request. /// diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift index ea64d9aa..7272e483 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorCredentialSourceIdentifier.swift @@ -14,7 +14,7 @@ import Foundation -public protocol AuthenticatorCredentialSourceIdentifier: Hashable { +public protocol AuthenticatorCredentialSourceIdentifier: Hashable, Sendable { init?(bytes: some BidirectionalCollection) var bytes: [UInt8] { get } } diff --git a/Sources/WebAuthn/WebAuthnClient.swift b/Sources/WebAuthn/WebAuthnClient.swift index c08fc627..7c899c7f 100644 --- a/Sources/WebAuthn/WebAuthnClient.swift +++ b/Sources/WebAuthn/WebAuthnClient.swift @@ -20,6 +20,8 @@ import Crypto /// - Important: Unless you specifically need to implement a custom WebAuthn client, it is vastly preferable to reach for the built-in [AuthenticationServices](https://developer.apple.com/documentation/authenticationservices) framework instead, which provides out-of-the-box support for a user's [Passkey](https://developer.apple.com/documentation/authenticationservices/public-private_key_authentication/supporting_passkeys). However, this is not always possible or preferrable to use this credential, especially when you want to implement silent account creation, and wish to build it off of WebAuthn. For those cases, `WebAuthnClient` is available. /// /// Registration: To create a registration credential, first ask the relying party (aka the server) for ``PublicKeyCredentialCreationOptions``, then pass those to ``createRegistrationCredential(options:minTimeout:maxTimeout:origin:supportedPublicKeyCredentialParameters:attestRegistration:)`` along with a closure that can generate credentials from configured ``AuthenticatorProtocol`` types such as ``KeyPairAuthenticator`` by passing the provided ``AttestationRegistration`` to ``AuthenticatorProtocol/makeCredentials(with:)``, making sure to persist the resulting ``AuthenticatorProtocol/CredentialSource`` in some way. Finally, pass the resulting ``RegistrationCredential`` back to the relying party to finish registration. +/// Authentication: To retrieve an authentication credential, first ask the relying party (aka the server) for ``PublicKeyCredentialRequestOptions``, then pass those to ``getAuthenticationCredential(options:minTimeout:maxTimeout:origin:assertAuthentication:)`` along with a closure that can validate credentials from configured ``AuthenticatorProtocol`` types such as ``KeyPairAuthenticator`` by passing the provided ``AssertionAuthentication`` to ``AuthenticatorProtocol/validateCredentials(with:)``, making sure to persist the resulting ``AuthenticatorProtocol/CredentialSource`` in some way. Finally, pass the resulting ``AuthenticationCredential`` back to the relying party to finish registration. +/// public struct WebAuthnClient { public init() {} @@ -287,6 +289,24 @@ public struct WebAuthnClient { throw error } } + + public func assertAuthenticationCredential( + options: PublicKeyCredentialRequestOptions, + /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout + minTimeout: Duration = .seconds(300), + maxTimeout: Duration = .seconds(600), + origin: String, +// mediation: , + assertAuthentication: (_ authentication: AssertionAuthenticationRequest) async throws -> () + ) async throws -> AuthenticationCredential { + /* + 1. Perform setup and massage inputs + 2. Prepare callback for assertion that an authenticator can call + 3. Have authenticator validate and sign a provided credential that matches it, calling the authentication callback + 4. Prepare final deliverable and cancel in-progress authenticators + */ + throw WebAuthnError.unsupported + } } // MARK: Convenience Registration and Authentication @@ -365,4 +385,91 @@ extension WebAuthnClient { return (registrationCredential, credentialSources) } + + @inlinable + public func assertAuthenticationCredential( + options: PublicKeyCredentialRequestOptions, + /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout + minTimeout: Duration = .seconds(300), + maxTimeout: Duration = .seconds(600), + origin: String, +// mediation: , + authenticator: Authenticator, + credentialStore: CredentialStore + ) async throws -> ( + authenticationCredential: AuthenticationCredential, + updatedCredentialSource: Authenticator.CredentialSource + ) { + var credentialSource: Authenticator.CredentialSource? + let authenticationCredential = try await assertAuthenticationCredential( + options: options, + minTimeout: minTimeout, + maxTimeout: maxTimeout, + origin: origin + ) { authentication in + credentialSource = try await authenticator.assertCredentials( + authenticationRequest: authentication, + credentials: credentialStore + ) + } + + guard let credentialSource + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } + + return (authenticationCredential, credentialSource) + } + + @inlinable + public func assertAuthenticationCredential( + options: PublicKeyCredentialRequestOptions, + /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout + minTimeout: Duration = .seconds(300), + maxTimeout: Duration = .seconds(600), + origin: String, +// mediation: , + authenticators: repeat each Authenticator, + credentialStores: repeat CredentialStore<(each Authenticator)> + ) async throws -> ( + authenticationCredential: AuthenticationCredential, + updatedCredentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>) + ) { + /// Wrapper function since `repeat` doesn't currently support complex expressions + @Sendable func authenticate( + authenticator: LocalAuthenticator, + authentication: AssertionAuthenticationRequest, + credentials: CredentialStore + ) -> Task { + Task { + try await authenticator.assertCredentials( + authenticationRequest: authentication, + credentials: credentials + ) + } + } + + var credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>)? + let authenticationCredential = try await assertAuthenticationCredential( + options: options, + minTimeout: minTimeout, + maxTimeout: maxTimeout, + origin: origin + ) { authentication in + /// Run each authenticator in parallel as child tasks, so we can automatically propagate cancellation to each of them should it occur. + let tasks = (repeat authenticate( + authenticator: each authenticators, + authentication: authentication, + credentials: each credentialStores + )) + await withTaskCancellationHandler { + credentialSources = (repeat await (each tasks).result) + } onCancel: { + repeat (each tasks).cancel() + } + } + + guard let credentialSources + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } + + return (authenticationCredential, credentialSources) + } } From c416dcb25cfac517a4ab021b9983285fd2b26628 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Tue, 20 Feb 2024 01:07:41 -0800 Subject: [PATCH 23/29] Updated PublicKeyCredentialRequestOptions to be Codable --- .../PublicKeyCredentialRequestOptions.swift | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/Sources/WebAuthn/Ceremonies/Authentication/PublicKeyCredentialRequestOptions.swift b/Sources/WebAuthn/Ceremonies/Authentication/PublicKeyCredentialRequestOptions.swift index ab6a2ff2..98f1579a 100644 --- a/Sources/WebAuthn/Ceremonies/Authentication/PublicKeyCredentialRequestOptions.swift +++ b/Sources/WebAuthn/Ceremonies/Authentication/PublicKeyCredentialRequestOptions.swift @@ -19,7 +19,7 @@ import Foundation /// When encoding using `Encodable`, the byte arrays are encoded as base64url. /// /// - SeeAlso: https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options -public struct PublicKeyCredentialRequestOptions: Encodable, Sendable { +public struct PublicKeyCredentialRequestOptions: Sendable { /// A challenge that the authenticator signs, along with other data, when producing an authentication assertion /// /// When encoding using `Encodable` this is encoded as base64url. @@ -45,7 +45,19 @@ public struct PublicKeyCredentialRequestOptions: Encodable, Sendable { public let userVerification: UserVerificationRequirement? // let extensions: [String: Any] +} +extension PublicKeyCredentialRequestOptions: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + challenge = try container.decodeBytesFromURLEncodedBase64(forKey: .challenge) + timeout = try container.decodeIfPresent(UInt32.self, forKey: .timeout).map { .milliseconds($0) } + relyingPartyID = try container.decode(String.self, forKey: .rpID) + allowCredentials = try container.decodeIfPresent([PublicKeyCredentialDescriptor].self, forKey: .allowCredentials) + userVerification = try container.decodeIfPresent(UserVerificationRequirement.self, forKey: .userVerification) + } + public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -68,10 +80,10 @@ public struct PublicKeyCredentialRequestOptions: Encodable, Sendable { /// Information about a generated credential. /// /// When encoding using `Encodable`, `id` is encoded as base64url. -public struct PublicKeyCredentialDescriptor: Equatable, Encodable, Sendable { +public struct PublicKeyCredentialDescriptor: Equatable, Codable, Sendable { /// Defines hints as to how clients might communicate with a particular authenticator in order to obtain an /// assertion for a specific credential - public enum AuthenticatorTransport: String, Equatable, Encodable, Sendable { + public enum AuthenticatorTransport: String, Equatable, Codable, Sendable { /// Indicates the respective authenticator can be contacted over removable USB. case usb /// Indicates the respective authenticator can be contacted over Near Field Communication (NFC). @@ -107,6 +119,14 @@ public struct PublicKeyCredentialDescriptor: Equatable, Encodable, Sendable { self.id = id self.transports = transports } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + type = try container.decode(CredentialType.self, forKey: .type) + id = try container.decodeBytesFromURLEncodedBase64(forKey: .id) + transports = try container.decode([AuthenticatorTransport].self, forKey: .transports) + } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -125,7 +145,7 @@ public struct PublicKeyCredentialDescriptor: Equatable, Encodable, Sendable { /// The Relying Party may require user verification for some of its operations but not for others, and may use this /// type to express its needs. -public enum UserVerificationRequirement: String, Encodable, Sendable { +public enum UserVerificationRequirement: String, Codable, Sendable { /// The Relying Party requires user verification for the operation and will fail the overall ceremony if the /// user wasn't verified. case required From c05656a8ddaf65d6b9724746aaad1ab0922a066b Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Tue, 20 Feb 2024 01:16:32 -0800 Subject: [PATCH 24/29] Updated AuthenticationCredential to be Codable --- .../Authentication/AuthenticationCredential.swift | 12 +++++++++++- .../AuthenticatorAssertionResponse.swift | 12 +++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift b/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift index 2ec597b6..8f538cc7 100644 --- a/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift +++ b/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift @@ -35,7 +35,7 @@ public struct AuthenticationCredential: Sendable { public let type: CredentialType } -extension AuthenticationCredential: Decodable { +extension AuthenticationCredential: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -45,6 +45,16 @@ extension AuthenticationCredential: Decodable { authenticatorAttachment = try container.decodeIfPresent(AuthenticatorAttachment.self, forKey: .authenticatorAttachment) type = try container.decode(CredentialType.self, forKey: .type) } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id, forKey: .id) + try container.encode(rawID.base64URLEncodedString(), forKey: .rawID) + try container.encode(response, forKey: .response) + try container.encodeIfPresent(authenticatorAttachment, forKey: .authenticatorAttachment) + try container.encode(type, forKey: .type) + } private enum CodingKeys: String, CodingKey { case id diff --git a/Sources/WebAuthn/Ceremonies/Authentication/AuthenticatorAssertionResponse.swift b/Sources/WebAuthn/Ceremonies/Authentication/AuthenticatorAssertionResponse.swift index a97d10ce..ea519518 100644 --- a/Sources/WebAuthn/Ceremonies/Authentication/AuthenticatorAssertionResponse.swift +++ b/Sources/WebAuthn/Ceremonies/Authentication/AuthenticatorAssertionResponse.swift @@ -49,7 +49,7 @@ public struct AuthenticatorAssertionResponse: Sendable { public let attestationObject: [UInt8]? } -extension AuthenticatorAssertionResponse: Decodable { +extension AuthenticatorAssertionResponse: Codable { public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -59,6 +59,16 @@ extension AuthenticatorAssertionResponse: Decodable { userHandle = try container.decodeBytesFromURLEncodedBase64IfPresent(forKey: .userHandle) attestationObject = try container.decodeBytesFromURLEncodedBase64IfPresent(forKey: .attestationObject) } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(clientDataJSON.base64URLEncodedString(), forKey: .clientDataJSON) + try container.encode(authenticatorData.base64URLEncodedString(), forKey: .authenticatorData) + try container.encode(signature.base64URLEncodedString(), forKey: .signature) + try container.encodeIfPresent(userHandle?.base64URLEncodedString(), forKey: .userHandle) + try container.encodeIfPresent(attestationObject?.base64URLEncodedString(), forKey: .attestationObject) + } private enum CodingKeys: String, CodingKey { case clientDataJSON From 2f0dc0c65873a40b33221388afdf59fcd0175d0f Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Tue, 20 Feb 2024 01:35:45 -0800 Subject: [PATCH 25/29] Added documentation steps for client authentication --- Sources/WebAuthn/WebAuthnClient.swift | 133 ++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/Sources/WebAuthn/WebAuthnClient.swift b/Sources/WebAuthn/WebAuthnClient.swift index 7c899c7f..db8759f3 100644 --- a/Sources/WebAuthn/WebAuthnClient.swift +++ b/Sources/WebAuthn/WebAuthnClient.swift @@ -299,6 +299,139 @@ public struct WebAuthnClient { // mediation: , assertAuthentication: (_ authentication: AssertionAuthenticationRequest) async throws -> () ) async throws -> AuthenticationCredential { + /// See https://w3c.github.io/webauthn/#sctn-discover-from-external-source + /// Step 1. Assert: options.publicKey is present. + /// Step 2. Let pkOptions be the value of options.publicKey. + /// Step 3. If options.mediation is present with the value conditional: + /// 1. Let credentialIdFilter be the value of pkOptions.allowCredentials. + /// 2. Set pkOptions.allowCredentials to empty. + /// NOTE: This prevents non-discoverable credentials from being used during conditional requests. + /// 3. Set a timer lifetimeTimer to a value of infinity. + /// NOTE: lifetimeTimer is set to a value of infinity so that the user has the entire lifetime of the Document to interact with any input form control tagged with a "webauthn" autofill detail token. For example, upon the user clicking in such an input field, the user agent can render a list of discovered credentials for the user to select from, and perhaps also give the user the option to "try another way". + /// Step 4. Else: + /// 1. Let credentialIdFilter be an empty list. + /// 2. If pkOptions.timeout is present, check if its value lies within a reasonable range as defined by the client and if not, correct it to the closest value lying within that range. Set a timer lifetimeTimer to this adjusted value. If pkOptions.timeout is not present, then set lifetimeTimer to a client-specific default. + /// See the recommended range and default for a WebAuthn ceremony timeout for guidance on deciding a reasonable range and default for pkOptions.timeout. + /// NOTE: The user agent should take cognitive guidelines into considerations regarding timeout for users with special needs. + /// Step 5. Let callerOrigin be origin. If callerOrigin is an opaque origin, throw a "NotAllowedError" DOMException. + /// Step 6. Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then throw a "SecurityError" DOMException. + /// NOTE: An effective domain may resolve to a host, which can be represented in various manners, such as domain, ipv4 address, ipv6 address, opaque host, or empty host. Only the domain format of host is allowed here. This is for simplification and also is in recognition of various issues with using direct IP address identification in concert with PKI-based security. + /// Step 7. If pkOptions.rpId is not present, then set rpId to effectiveDomain. + /// Otherwise: + /// 1. If pkOptions.rpId is not a registrable domain suffix of and is not equal to effectiveDomain, throw a "SecurityError" DOMException. + /// 2. Set rpId to pkOptions.rpId. + /// NOTE: rpId represents the caller’s RP ID. The RP ID defaults to being the caller’s origin's effective domain unless the caller has explicitly set pkOptions.rpId when calling get(). + /// Step 8. Let clientExtensions be a new map and let authenticatorExtensions be a new map. + /// Step 9. If pkOptions.extensions is present, then for each extensionId → clientExtensionInput of pkOptions.extensions: + /// 1. If extensionId is not supported by this client platform or is not an authentication extension, then continue. + /// 2. Set clientExtensions[extensionId] to clientExtensionInput. + /// 3. If extensionId is not an authenticator extension, then continue. + /// 4. Let authenticatorExtensionInput be the (CBOR) result of running extensionId’s client extension processing algorithm on clientExtensionInput. If the algorithm returned an error, continue. + /// 5. Set authenticatorExtensions[extensionId] to the base64url encoding of authenticatorExtensionInput. + /// Step 10. Let collectedClientData be a new CollectedClientData instance whose fields are: + /// type + /// The string "webauthn.get". + /// challenge + /// The base64url encoding of pkOptions.challenge + /// origin + /// The serialization of callerOrigin. + /// topOrigin + /// The serialization of callerOrigin’s top-level origin if the sameOriginWithAncestors argument passed to this internal method is false, else undefined. + /// crossOrigin + /// The inverse of the value of the sameOriginWithAncestors argument passed to this internal method. + /// Step 11. Let clientDataJSON be the JSON-compatible serialization of client data constructed from collectedClientData. + /// Step 12. Let clientDataHash be the hash of the serialized client data represented by clientDataJSON. + /// Step 13. If options.signal is present and aborted, throw the options.signal’s abort reason. + /// Step 14. Let issuedRequests be a new ordered set. + /// Step 15. Let savedCredentialIds be a new map. + /// Step 16. Let authenticators represent a value which at any given instant is a set of client platform-specific handles, where each item identifies an authenticator presently available on this client platform at that instant. + /// NOTE: What qualifies an authenticator as "available" is intentionally unspecified; this is meant to represent how authenticators can be hot-plugged into (e.g., via USB) or discovered (e.g., via NFC or Bluetooth) by the client by various mechanisms, or permanently built into the client. + /// Step 17. Let silentlyDiscoveredCredentials be a new map whose entries are of the form: DiscoverableCredentialMetadata → authenticator. + /// Step 18. Consider the value of hints and craft the user interface accordingly, as the user-agent sees fit. + /// Step 19. Start lifetimeTimer. + /// Step 20. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: + /// → If lifetimeTimer expires, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. + /// → If the user exercises a user agent user-interface option to cancel the process, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Throw a "NotAllowedError" DOMException. + /// → If options.signal is present and aborted, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Then throw the options.signal’s abort reason. + /// → If options.mediation is conditional and the user interacts with an input or textarea form control with an autocomplete attribute whose non-autofill credential type is "webauthn", + /// Note: The "webauthn" autofill detail token must appear immediately after the last autofill detail token of type "Normal" or "Contact". For example: + /// "username webauthn" + /// "current-password webauthn" + /// 1. If silentlyDiscoveredCredentials is not empty: + /// 1. Prompt the user to optionally select a DiscoverableCredentialMetadata (credentialMetadata) from silentlyDiscoveredCredentials. + /// NOTE: The prompt shown SHOULD include values from credentialMetadata’s otherUI such as name and displayName. + /// 2. If the user selects a credentialMetadata, + /// 1. Let publicKeyOptions be a temporary copy of pkOptions. + /// 2. Let authenticator be the value of silentlyDiscoveredCredentials[credentialMetadata]. + /// 3. Set publicKeyOptions.allowCredentials to be a list containing a single PublicKeyCredentialDescriptor item whose id's value is set to credentialMetadata’s id's value and whoseid value is set to credentialMetadata’s type. + /// 4. Execute the issuing a credential request to an authenticator algorithm with authenticator, savedCredentialIds, publicKeyOptions, rpId, clientDataHash, and authenticatorExtensions. + /// If this returns false, continue. + /// 5. Append authenticator to issuedRequests. + /// → If options.mediation is not conditional, issuedRequests is empty, pkOptions.allowCredentials is not empty, and no authenticator will become available for any public key credentials therein, + /// Indicate to the user that no eligible credential could be found. When the user acknowledges the dialog, throw a "NotAllowedError" DOMException. + /// NOTE: One way a client platform can determine that no authenticator will become available is by examining the transports members of the present PublicKeyCredentialDescriptor items of pkOptions.allowCredentials, if any. For example, if all PublicKeyCredentialDescriptor items list only internal, but all platform authenticators have been tried, then there is no possibility of satisfying the request. Alternatively, all PublicKeyCredentialDescriptor items may list transports that the client platform does not support. + /// → If an authenticator becomes available on this client device, + /// NOTE: This includes the case where an authenticator was available upon lifetimeTimer initiation. + /// 1. If options.mediation is conditional and the authenticator supports the silentCredentialDiscovery operation: + /// 1. Let collectedDiscoveredCredentialMetadata be the result of invoking the silentCredentialDiscovery operation on authenticator with rpId as parameter. + /// 2. For each credentialMetadata of collectedDiscoveredCredentialMetadata: + /// 1. If credentialIdFilter is empty or credentialIdFilter contains an item whose id's value is set to credentialMetadata’s id, set silentlyDiscoveredCredentials[credentialMetadata] to authenticator. + /// NOTE: A request will be issued to this authenticator upon user selection of a credential via interaction with a particular UI context (see here for details). + /// 2. Else: + /// 1. Execute the issuing a credential request to an authenticator algorithm with authenticator, savedCredentialIds, pkOptions, rpId, clientDataHash, and authenticatorExtensions. + /// If this returns false, continue. + /// NOTE: This branch is taken if options.mediation is conditional and the authenticator does not support the silentCredentialDiscovery operation to allow use of such authenticators during a conditional user mediation request. + /// 2. Append authenticator to issuedRequests. + /// → If an authenticator ceases to be available on this client device, + /// Remove authenticator from issuedRequests. + /// → If any authenticator returns a status indicating that the user cancelled the operation, + /// 1. Remove authenticator from issuedRequests. + /// 2. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. + /// NOTE: Authenticators may return an indication of "the user cancelled the entire operation". How a user agent manifests this state to users is unspecified. + /// → If any authenticator returns an error status, + /// Remove authenticator from issuedRequests. + /// → If any authenticator indicates success, + /// 1. Remove authenticator from issuedRequests. + /// 2. Let assertionCreationData be a struct whose items are: + /// credentialIdResult + /// If savedCredentialIds[authenticator] exists, set the value of credentialIdResult to be the bytes of savedCredentialIds[authenticator]. Otherwise, set the value of credentialIdResult to be the bytes of the credential ID returned from the successful authenticatorGetAssertion operation, as defined in § 6.3.3 The authenticatorGetAssertion Operation. + /// clientDataJSONResult + /// whose value is the bytes of clientDataJSON. + /// authenticatorDataResult + /// whose value is the bytes of the authenticator data returned by the authenticator. + /// signatureResult + /// whose value is the bytes of the signature value returned by the authenticator. + /// userHandleResult + /// If the authenticator returned a user handle, set the value of userHandleResult to be the bytes of the returned user handle. Otherwise, set the value of userHandleResult to null. + /// clientExtensionResults + /// whose value is an AuthenticationExtensionsClientOutputs object containing extension identifier → client extension output entries. The entries are created by running each extension’s client extension processing algorithm to create the client extension outputs, for each client extension in pkOptions.extensions. + /// 3. If credentialIdFilter is not empty and credentialIdFilter does not contain an item whose id's value is set to the value of credentialIdResult, continue. + /// 4. If credentialIdFilter is empty and userHandleResult is null, continue. + /// 5. Let constructAssertionAlg be an algorithm that takes a global object global, and whose steps are: + /// 1. Let pubKeyCred be a new PublicKeyCredential object associated with global whose fields are: + /// [[identifier]] + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.credentialIdResult. + /// authenticatorAttachment + /// The AuthenticatorAttachment value matching the current authenticator attachment modality of authenticator. + /// response + /// A new AuthenticatorAssertionResponse object associated with global whose fields are: + /// clientDataJSON + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.clientDataJSONResult. + /// authenticatorData + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.authenticatorDataResult. + /// signature + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.signatureResult. + /// userHandle + /// If assertionCreationData.userHandleResult is null, set this field to null. Otherwise, set this field to a new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.userHandleResult. + /// [[clientExtensionsResults]] + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.clientExtensionResults. + /// 2. Return pubKeyCred. + /// 6. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. + /// 7. Return constructAssertionAlg and terminate this algorithm. + /// Step 31. Throw a "NotAllowedError" DOMException. In order to prevent information leak that could identify the user without consent, this step MUST NOT be executed before lifetimeTimer has expired. See § 14.5.2 Authentication Ceremony Privacy for details. /* 1. Perform setup and massage inputs 2. Prepare callback for assertion that an authenticator can call From d4caffa48c47778766d4db2b732b4f562f9b7822 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 23 Feb 2024 05:12:41 -0800 Subject: [PATCH 26/29] Added basic client authentication ceremony --- .../AuthenticationCredential.swift | 13 + Sources/WebAuthn/WebAuthnClient.swift | 306 ++++++++++++------ .../WebAuthnManagerAuthenticationTests.swift | 9 +- .../WebAuthnManagerIntegrationTests.swift | 8 +- 4 files changed, 227 insertions(+), 109 deletions(-) diff --git a/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift b/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift index 8f538cc7..e255f83a 100644 --- a/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift +++ b/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift @@ -33,6 +33,19 @@ public struct AuthenticationCredential: Sendable { /// Value will always be ``CredentialType/publicKey`` (for now) public let type: CredentialType + + init( + type: CredentialType = .publicKey, + id: [UInt8], + authenticatorAttachment: AuthenticatorAttachment?, + response: AuthenticatorAssertionResponse + ) { + self.id = id.base64URLEncodedString() + self.rawID = id + self.response = response + self.authenticatorAttachment = authenticatorAttachment + self.type = type + } } extension AuthenticationCredential: Codable { diff --git a/Sources/WebAuthn/WebAuthnClient.swift b/Sources/WebAuthn/WebAuthnClient.swift index db8759f3..0220bd95 100644 --- a/Sources/WebAuthn/WebAuthnClient.swift +++ b/Sources/WebAuthn/WebAuthnClient.swift @@ -301,144 +301,252 @@ public struct WebAuthnClient { ) async throws -> AuthenticationCredential { /// See https://w3c.github.io/webauthn/#sctn-discover-from-external-source /// Step 1. Assert: options.publicKey is present. + // Skip, already is. + /// Step 2. Let pkOptions be the value of options.publicKey. + // Skip, already is. + /// Step 3. If options.mediation is present with the value conditional: /// 1. Let credentialIdFilter be the value of pkOptions.allowCredentials. /// 2. Set pkOptions.allowCredentials to empty. /// NOTE: This prevents non-discoverable credentials from being used during conditional requests. /// 3. Set a timer lifetimeTimer to a value of infinity. /// NOTE: lifetimeTimer is set to a value of infinity so that the user has the entire lifetime of the Document to interact with any input form control tagged with a "webauthn" autofill detail token. For example, upon the user clicking in such an input field, the user agent can render a list of discovered credentials for the user to select from, and perhaps also give the user the option to "try another way". + // Skip. + /// Step 4. Else: /// 1. Let credentialIdFilter be an empty list. + // Skip. + /// 2. If pkOptions.timeout is present, check if its value lies within a reasonable range as defined by the client and if not, correct it to the closest value lying within that range. Set a timer lifetimeTimer to this adjusted value. If pkOptions.timeout is not present, then set lifetimeTimer to a client-specific default. /// See the recommended range and default for a WebAuthn ceremony timeout for guidance on deciding a reasonable range and default for pkOptions.timeout. /// NOTE: The user agent should take cognitive guidelines into considerations regarding timeout for users with special needs. + let proposedTimeout = options.timeout ?? minTimeout + let timeout = max(minTimeout, min(proposedTimeout, maxTimeout)) + /// Step 5. Let callerOrigin be origin. If callerOrigin is an opaque origin, throw a "NotAllowedError" DOMException. + let callerOrigin = origin + /// Step 6. Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then throw a "SecurityError" DOMException. /// NOTE: An effective domain may resolve to a host, which can be represented in various manners, such as domain, ipv4 address, ipv6 address, opaque host, or empty host. Only the domain format of host is allowed here. This is for simplification and also is in recognition of various issues with using direct IP address identification in concert with PKI-based security. + // Skip. + /// Step 7. If pkOptions.rpId is not present, then set rpId to effectiveDomain. /// Otherwise: /// 1. If pkOptions.rpId is not a registrable domain suffix of and is not equal to effectiveDomain, throw a "SecurityError" DOMException. /// 2. Set rpId to pkOptions.rpId. /// NOTE: rpId represents the caller’s RP ID. The RP ID defaults to being the caller’s origin's effective domain unless the caller has explicitly set pkOptions.rpId when calling get(). + // Skip. + /// Step 8. Let clientExtensions be a new map and let authenticatorExtensions be a new map. + // Skip. + /// Step 9. If pkOptions.extensions is present, then for each extensionId → clientExtensionInput of pkOptions.extensions: /// 1. If extensionId is not supported by this client platform or is not an authentication extension, then continue. /// 2. Set clientExtensions[extensionId] to clientExtensionInput. /// 3. If extensionId is not an authenticator extension, then continue. /// 4. Let authenticatorExtensionInput be the (CBOR) result of running extensionId’s client extension processing algorithm on clientExtensionInput. If the algorithm returned an error, continue. /// 5. Set authenticatorExtensions[extensionId] to the base64url encoding of authenticatorExtensionInput. + // Skip. + /// Step 10. Let collectedClientData be a new CollectedClientData instance whose fields are: - /// type - /// The string "webauthn.get". - /// challenge - /// The base64url encoding of pkOptions.challenge - /// origin - /// The serialization of callerOrigin. - /// topOrigin - /// The serialization of callerOrigin’s top-level origin if the sameOriginWithAncestors argument passed to this internal method is false, else undefined. - /// crossOrigin - /// The inverse of the value of the sameOriginWithAncestors argument passed to this internal method. + let collectedClientData = CollectedClientData( + /// type + /// The string "webauthn.get". + type: .assert, + /// challenge + /// The base64url encoding of pkOptions.challenge + challenge: options.challenge.base64URLEncodedString(), + /// origin + /// The serialization of callerOrigin. + origin: callerOrigin + /// topOrigin + /// The serialization of callerOrigin’s top-level origin if the sameOriginWithAncestors argument passed to this internal method is false, else undefined. + // Skip. + /// crossOrigin + /// The inverse of the value of the sameOriginWithAncestors argument passed to this internal method. + // Skip. + ) + /// Step 11. Let clientDataJSON be the JSON-compatible serialization of client data constructed from collectedClientData. + let clientDataJSON = try JSONEncoder().encode(collectedClientData) + /// Step 12. Let clientDataHash be the hash of the serialized client data represented by clientDataJSON. + let clientDataHash = SHA256.hash(data: clientDataJSON) + /// Step 13. If options.signal is present and aborted, throw the options.signal’s abort reason. + // Skip. + /// Step 14. Let issuedRequests be a new ordered set. + // Skip. + /// Step 15. Let savedCredentialIds be a new map. + // Skip. + /// Step 16. Let authenticators represent a value which at any given instant is a set of client platform-specific handles, where each item identifies an authenticator presently available on this client platform at that instant. /// NOTE: What qualifies an authenticator as "available" is intentionally unspecified; this is meant to represent how authenticators can be hot-plugged into (e.g., via USB) or discovered (e.g., via NFC or Bluetooth) by the client by various mechanisms, or permanently built into the client. + // Skip. + /// Step 17. Let silentlyDiscoveredCredentials be a new map whose entries are of the form: DiscoverableCredentialMetadata → authenticator. + // Skip. + /// Step 18. Consider the value of hints and craft the user interface accordingly, as the user-agent sees fit. + // Skip. + /// Step 19. Start lifetimeTimer. + let timeoutTask = Task { try? await Task.sleep(for: timeout) } + /// Step 20. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: - /// → If lifetimeTimer expires, - /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. - /// → If the user exercises a user agent user-interface option to cancel the process, - /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Throw a "NotAllowedError" DOMException. - /// → If options.signal is present and aborted, - /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Then throw the options.signal’s abort reason. - /// → If options.mediation is conditional and the user interacts with an input or textarea form control with an autocomplete attribute whose non-autofill credential type is "webauthn", - /// Note: The "webauthn" autofill detail token must appear immediately after the last autofill detail token of type "Normal" or "Contact". For example: - /// "username webauthn" - /// "current-password webauthn" - /// 1. If silentlyDiscoveredCredentials is not empty: - /// 1. Prompt the user to optionally select a DiscoverableCredentialMetadata (credentialMetadata) from silentlyDiscoveredCredentials. - /// NOTE: The prompt shown SHOULD include values from credentialMetadata’s otherUI such as name and displayName. - /// 2. If the user selects a credentialMetadata, - /// 1. Let publicKeyOptions be a temporary copy of pkOptions. - /// 2. Let authenticator be the value of silentlyDiscoveredCredentials[credentialMetadata]. - /// 3. Set publicKeyOptions.allowCredentials to be a list containing a single PublicKeyCredentialDescriptor item whose id's value is set to credentialMetadata’s id's value and whoseid value is set to credentialMetadata’s type. - /// 4. Execute the issuing a credential request to an authenticator algorithm with authenticator, savedCredentialIds, publicKeyOptions, rpId, clientDataHash, and authenticatorExtensions. - /// If this returns false, continue. - /// 5. Append authenticator to issuedRequests. - /// → If options.mediation is not conditional, issuedRequests is empty, pkOptions.allowCredentials is not empty, and no authenticator will become available for any public key credentials therein, - /// Indicate to the user that no eligible credential could be found. When the user acknowledges the dialog, throw a "NotAllowedError" DOMException. - /// NOTE: One way a client platform can determine that no authenticator will become available is by examining the transports members of the present PublicKeyCredentialDescriptor items of pkOptions.allowCredentials, if any. For example, if all PublicKeyCredentialDescriptor items list only internal, but all platform authenticators have been tried, then there is no possibility of satisfying the request. Alternatively, all PublicKeyCredentialDescriptor items may list transports that the client platform does not support. - /// → If an authenticator becomes available on this client device, - /// NOTE: This includes the case where an authenticator was available upon lifetimeTimer initiation. - /// 1. If options.mediation is conditional and the authenticator supports the silentCredentialDiscovery operation: - /// 1. Let collectedDiscoveredCredentialMetadata be the result of invoking the silentCredentialDiscovery operation on authenticator with rpId as parameter. - /// 2. For each credentialMetadata of collectedDiscoveredCredentialMetadata: - /// 1. If credentialIdFilter is empty or credentialIdFilter contains an item whose id's value is set to credentialMetadata’s id, set silentlyDiscoveredCredentials[credentialMetadata] to authenticator. - /// NOTE: A request will be issued to this authenticator upon user selection of a credential via interaction with a particular UI context (see here for details). - /// 2. Else: - /// 1. Execute the issuing a credential request to an authenticator algorithm with authenticator, savedCredentialIds, pkOptions, rpId, clientDataHash, and authenticatorExtensions. - /// If this returns false, continue. - /// NOTE: This branch is taken if options.mediation is conditional and the authenticator does not support the silentCredentialDiscovery operation to allow use of such authenticators during a conditional user mediation request. - /// 2. Append authenticator to issuedRequests. - /// → If an authenticator ceases to be available on this client device, - /// Remove authenticator from issuedRequests. - /// → If any authenticator returns a status indicating that the user cancelled the operation, - /// 1. Remove authenticator from issuedRequests. - /// 2. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. - /// NOTE: Authenticators may return an indication of "the user cancelled the entire operation". How a user agent manifests this state to users is unspecified. - /// → If any authenticator returns an error status, - /// Remove authenticator from issuedRequests. - /// → If any authenticator indicates success, - /// 1. Remove authenticator from issuedRequests. - /// 2. Let assertionCreationData be a struct whose items are: - /// credentialIdResult - /// If savedCredentialIds[authenticator] exists, set the value of credentialIdResult to be the bytes of savedCredentialIds[authenticator]. Otherwise, set the value of credentialIdResult to be the bytes of the credential ID returned from the successful authenticatorGetAssertion operation, as defined in § 6.3.3 The authenticatorGetAssertion Operation. - /// clientDataJSONResult - /// whose value is the bytes of clientDataJSON. - /// authenticatorDataResult - /// whose value is the bytes of the authenticator data returned by the authenticator. - /// signatureResult - /// whose value is the bytes of the signature value returned by the authenticator. - /// userHandleResult - /// If the authenticator returned a user handle, set the value of userHandleResult to be the bytes of the returned user handle. Otherwise, set the value of userHandleResult to null. - /// clientExtensionResults - /// whose value is an AuthenticationExtensionsClientOutputs object containing extension identifier → client extension output entries. The entries are created by running each extension’s client extension processing algorithm to create the client extension outputs, for each client extension in pkOptions.extensions. - /// 3. If credentialIdFilter is not empty and credentialIdFilter does not contain an item whose id's value is set to the value of credentialIdResult, continue. - /// 4. If credentialIdFilter is empty and userHandleResult is null, continue. - /// 5. Let constructAssertionAlg be an algorithm that takes a global object global, and whose steps are: - /// 1. Let pubKeyCred be a new PublicKeyCredential object associated with global whose fields are: - /// [[identifier]] - /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.credentialIdResult. - /// authenticatorAttachment - /// The AuthenticatorAttachment value matching the current authenticator attachment modality of authenticator. - /// response - /// A new AuthenticatorAssertionResponse object associated with global whose fields are: - /// clientDataJSON - /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.clientDataJSONResult. - /// authenticatorData - /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.authenticatorDataResult. - /// signature - /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.signatureResult. - /// userHandle - /// If assertionCreationData.userHandleResult is null, set this field to null. Otherwise, set this field to a new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.userHandleResult. - /// [[clientExtensionsResults]] - /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.clientExtensionResults. - /// 2. Return pubKeyCred. - /// 6. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. - /// 7. Return constructAssertionAlg and terminate this algorithm. - /// Step 31. Throw a "NotAllowedError" DOMException. In order to prevent information leak that could identify the user without consent, this step MUST NOT be executed before lifetimeTimer has expired. See § 14.5.2 Authentication Ceremony Privacy for details. - /* - 1. Perform setup and massage inputs - 2. Prepare callback for assertion that an authenticator can call - 3. Have authenticator validate and sign a provided credential that matches it, calling the authentication callback - 4. Prepare final deliverable and cancel in-progress authenticators - */ - throw WebAuthnError.unsupported + do { + /// Let the caller do what it needs to do to coordinate with authenticators, so long as at least one of them calls the assertion callback. + let assertionResults: AssertionAuthenticationRequest.Results = try await withCancellableFirstSuccessfulContinuation { [assertAuthentication] continuation in + /// → If lifetimeTimer expires, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. + Task { + /// Let the timer run in the background to cancel the continuation if it runs over. + await timeoutTask.value + continuation.cancel() // TODO: Should be a timeout error + } + + /// → If the user exercises a user agent user-interface option to cancel the process, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Throw a "NotAllowedError" DOMException. + // Skip. + + /// → If options.signal is present and aborted, + /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. Then throw the options.signal’s abort reason. + // Skip. + + /// → If options.mediation is conditional and the user interacts with an input or textarea form control with an autocomplete attribute whose non-autofill credential type is "webauthn", + /// Note: The "webauthn" autofill detail token must appear immediately after the last autofill detail token of type "Normal" or "Contact". For example: + /// "username webauthn" + /// "current-password webauthn" + /// 1. If silentlyDiscoveredCredentials is not empty: + /// 1. Prompt the user to optionally select a DiscoverableCredentialMetadata (credentialMetadata) from silentlyDiscoveredCredentials. + /// NOTE: The prompt shown SHOULD include values from credentialMetadata’s otherUI such as name and displayName. + /// 2. If the user selects a credentialMetadata, + /// 1. Let publicKeyOptions be a temporary copy of pkOptions. + /// 2. Let authenticator be the value of silentlyDiscoveredCredentials[credentialMetadata]. + /// 3. Set publicKeyOptions.allowCredentials to be a list containing a single PublicKeyCredentialDescriptor item whose id's value is set to credentialMetadata’s id's value and whoseid value is set to credentialMetadata’s type. + /// 4. Execute the issuing a credential request to an authenticator algorithm with authenticator, savedCredentialIds, publicKeyOptions, rpId, clientDataHash, and authenticatorExtensions. + /// If this returns false, continue. + /// 5. Append authenticator to issuedRequests. + // Skip. + + /// → If options.mediation is not conditional, issuedRequests is empty, pkOptions.allowCredentials is not empty, and no authenticator will become available for any public key credentials therein, + /// Indicate to the user that no eligible credential could be found. When the user acknowledges the dialog, throw a "NotAllowedError" DOMException. + /// NOTE: One way a client platform can determine that no authenticator will become available is by examining the transports members of the present PublicKeyCredentialDescriptor items of pkOptions.allowCredentials, if any. For example, if all PublicKeyCredentialDescriptor items list only internal, but all platform authenticators have been tried, then there is no possibility of satisfying the request. Alternatively, all PublicKeyCredentialDescriptor items may list transports that the client platform does not support. + // Skip. + + /// → If an authenticator becomes available on this client device, + /// NOTE: This includes the case where an authenticator was available upon lifetimeTimer initiation. + /// 1. If options.mediation is conditional and the authenticator supports the silentCredentialDiscovery operation: + /// 1. Let collectedDiscoveredCredentialMetadata be the result of invoking the silentCredentialDiscovery operation on authenticator with rpId as parameter. + /// 2. For each credentialMetadata of collectedDiscoveredCredentialMetadata: + /// 1. If credentialIdFilter is empty or credentialIdFilter contains an item whose id's value is set to credentialMetadata’s id, set silentlyDiscoveredCredentials[credentialMetadata] to authenticator. + /// NOTE: A request will be issued to this authenticator upon user selection of a credential via interaction with a particular UI context (see here for details). + // Skip. + + /// 2. Else: + /// 1. Execute the issuing a credential request to an authenticator algorithm with authenticator, savedCredentialIds, pkOptions, rpId, clientDataHash, and authenticatorExtensions. + /// If this returns false, continue. + /// NOTE: This branch is taken if options.mediation is conditional and the authenticator does not support the silentCredentialDiscovery operation to allow use of such authenticators during a conditional user mediation request. + /// 2. Append authenticator to issuedRequests. + try await assertAuthentication(AssertionAuthenticationRequest( + options: options, + clientDataHash: clientDataHash, + attemptAuthentication: { assertionResults in + continuation.resume(returning: assertionResults) + } + )) + } + + /// → If an authenticator ceases to be available on this client device, + /// Remove authenticator from issuedRequests. + // Skip. + + /// → If any authenticator returns a status indicating that the user cancelled the operation, + /// 1. Remove authenticator from issuedRequests. + /// 2. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. + /// NOTE: Authenticators may return an indication of "the user cancelled the entire operation". How a user agent manifests this state to users is unspecified. + // Skip. + + /// → If any authenticator returns an error status, + /// Remove authenticator from issuedRequests. + // Skip. + + /// → If any authenticator indicates success, + /// 1. Remove authenticator from issuedRequests. + /// 2. Let assertionCreationData be a struct whose items are: + /// credentialIdResult + /// If savedCredentialIds[authenticator] exists, set the value of credentialIdResult to be the bytes of savedCredentialIds[authenticator]. Otherwise, set the value of credentialIdResult to be the bytes of the credential ID returned from the successful authenticatorGetAssertion operation, as defined in § 6.3.3 The authenticatorGetAssertion Operation. + /// clientDataJSONResult + /// whose value is the bytes of clientDataJSON. + /// authenticatorDataResult + /// whose value is the bytes of the authenticator data returned by the authenticator. + /// signatureResult + /// whose value is the bytes of the signature value returned by the authenticator. + /// userHandleResult + /// If the authenticator returned a user handle, set the value of userHandleResult to be the bytes of the returned user handle. Otherwise, set the value of userHandleResult to null. + /// clientExtensionResults + /// whose value is an AuthenticationExtensionsClientOutputs object containing extension identifier → client extension output entries. The entries are created by running each extension’s client extension processing algorithm to create the client extension outputs, for each client extension in pkOptions. + // Already created above. + + /// 3. If credentialIdFilter is not empty and credentialIdFilter does not contain an item whose id's value is set to the value of credentialIdResult, continue. + // SKip. + + /// 4. If credentialIdFilter is empty and userHandleResult is null, continue. + // SKip. + + /// 5. Let constructAssertionAlg be an algorithm that takes a global object global, and whose steps are: + /// 1. Let pubKeyCred be a new PublicKeyCredential object associated with global whose fields are: + let publicKeyCredential = AuthenticationCredential( + /// [[identifier]] + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.credentialIdResult. + id: assertionResults.credentialID, + /// authenticatorAttachment + /// The AuthenticatorAttachment value matching the current authenticator attachment modality of authenticator. + authenticatorAttachment: assertionResults.authenticatorAttachment, + /// response + /// A new AuthenticatorAssertionResponse object associated with global whose fields are: + response: AuthenticatorAssertionResponse( + /// clientDataJSON + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.clientDataJSONResult. + clientDataJSON: Array(clientDataJSON), + /// authenticatorData + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.authenticatorDataResult. + authenticatorData: assertionResults.authenticatorData, + /// signature + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.signatureResult. + signature: assertionResults.signature, + /// userHandle + /// If assertionCreationData.userHandleResult is null, set this field to null. Otherwise, set this field to a new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.userHandleResult. + userHandle: assertionResults.userHandle, + attestationObject: nil + ) + /// [[clientExtensionsResults]] + /// A new ArrayBuffer, created using global’s %ArrayBuffer%, containing the bytes of assertionCreationData.clientExtensionResults. + // Skip. + ) + /// 2. Return pubKeyCred. + // Returned below. + + /// 6. For each remaining authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove it from issuedRequests. + // Already performed. + + /// 7. Return constructAssertionAlg and terminate this algorithm. + return publicKeyCredential + } catch { + /// Step 31. Throw a "NotAllowedError" DOMException. In order to prevent information leak that could identify the user without consent, this step MUST NOT be executed before lifetimeTimer has expired. See § 14.5.2 Authentication Ceremony Privacy for details. + await withTaskCancellationHandler { + /// Make sure to wait until the timeout finishes if an error did occur. + await timeoutTask.value + } onCancel: { + /// However, if the user cancelled the process, stop the timer early. + timeoutTask.cancel() + } + /// Propagate the error originally thrown. + throw error + } } } diff --git a/Tests/WebAuthnTests/WebAuthnManagerAuthenticationTests.swift b/Tests/WebAuthnTests/WebAuthnManagerAuthenticationTests.swift index b23284c9..767d9687 100644 --- a/Tests/WebAuthnTests/WebAuthnManagerAuthenticationTests.swift +++ b/Tests/WebAuthnTests/WebAuthnManagerAuthenticationTests.swift @@ -174,17 +174,16 @@ final class WebAuthnManagerAuthenticationTests: XCTestCase { ) throws -> VerifiedAuthentication { try webAuthnManager.finishAuthentication( credential: AuthenticationCredential( - id: credentialID.base64URLEncodedString(), - rawID: credentialID, + type: type, + id: credentialID, + authenticatorAttachment: authenticatorAttachment, response: AuthenticatorAssertionResponse( clientDataJSON: clientDataJSON, authenticatorData: authenticatorData, signature: signature, userHandle: userHandle, attestationObject: attestationObject - ), - authenticatorAttachment: authenticatorAttachment, - type: type + ) ), expectedChallenge: expectedChallenge, credentialPublicKey: credentialPublicKey, diff --git a/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift b/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift index a453a74e..80cf702a 100644 --- a/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift +++ b/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift @@ -132,17 +132,15 @@ final class WebAuthnManagerIntegrationTests: XCTestCase { let signature = try TestECCKeyPair.signature(data: signatureBase).derRepresentation let authenticationCredential = AuthenticationCredential( - id: mockCredentialID.base64URLEncodedString(), - rawID: mockCredentialID, + id: mockCredentialID, + authenticatorAttachment: .platform, response: AuthenticatorAssertionResponse( clientDataJSON: clientData, authenticatorData: authenticatorData, signature: [UInt8](signature), userHandle: mockUser.id, attestationObject: nil - ), - authenticatorAttachment: .platform, - type: .publicKey + ) ) // Step 4.: Finish Authentication From b192e4aa07fd81da65d7ae1f80ba06b3a337d3cd Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Fri, 23 Feb 2024 05:04:59 -0800 Subject: [PATCH 27/29] Added assertion signing to authenticators --- .../Authenticators/KeyPairAuthenticator.swift | 2 + .../Protocol/AuthenticatorProtocol.swift | 141 +++++++++++++++++- Sources/WebAuthn/WebAuthnError.swift | 6 +- 3 files changed, 147 insertions(+), 2 deletions(-) diff --git a/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift b/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift index 6c6f04c7..93c5d6be 100644 --- a/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift +++ b/Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift @@ -19,6 +19,8 @@ public struct KeyPairAuthenticator: AuthenticatorProtocol, Sendable { public let attestationGloballyUniqueID: AAGUID public let attachmentModality: AuthenticatorAttachment public let supportedPublicKeyCredentialParameters: Set + + /// As the credentials are directly supplied by the caller, ``KeyPairAuthenticator``s are always capable of performing user verification, though they can be initialized to indicate silent authorization was performed if relevant. public let canPerformUserVerification: Bool = true public let canStoreCredentialSourceClientSide: Bool = true diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift index a0236d26..beaca064 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift @@ -345,6 +345,145 @@ extension AuthenticatorProtocol { authenticationRequest: AssertionAuthenticationRequest, credentials: CredentialStore ) async throws -> CredentialSource { - throw WebAuthnError.unsupported + /// [WebAuthn Level 3 Editor's Draft §5.1.4.2. Issuing a Credential Request to an Authenticator](https://w3c.github.io/webauthn/#sctn-issuing-cred-request-to-authenticator) + /// Step 1. If pkOptions.userVerification is set to required and the authenticator is not capable of performing user verification, return false. + if authenticationRequest.options.userVerification == .required && !canPerformUserVerification { + throw WebAuthnError.requiredUserVerificationNotSupported + } + + /// Step 2. Let userVerification be the effective user verification requirement for assertion, a Boolean value, as follows. If pkOptions.userVerification + let requestsUserVerification = switch authenticationRequest.options.userVerification { + /// → is set to required + /// Let userVerification be true. + case .required: true + /// → is set to preferred + /// If the authenticator + /// → is capable of user verification + /// Let userVerification be true. + /// → is not capable of user verification + /// Let userVerification be false. + case .preferred: canPerformUserVerification + /// → is set to discouraged + /// Let userVerification be false. + case .discouraged: false + /// Default to preferred case: [WebAuthn Level 3 Editor's Draft §5.5. Options for Assertion Generation (dictionary PublicKeyCredentialRequestOptions)](https://w3c.github.io/webauthn/#dom-publickeycredentialrequestoptions-userverification) + default: canPerformUserVerification + } + + /// Step 8. If pkOptions.allowCredentials + let allowedCredentialDescriptorList: [PublicKeyCredentialDescriptor] = if let allowCredentials = authenticationRequest.options.allowCredentials, !allowCredentials.isEmpty { + /// → is not empty + /// 1. Let allowCredentialDescriptorList be a new list. + /// 2. Execute a client platform-specific procedure to determine which, if any, public key credentials described by pkOptions.allowCredentials are bound to this authenticator, by matching with rpId, pkOptions.allowCredentials.id, and pkOptions.allowCredentials.type. Set allowCredentialDescriptorList to this filtered list. + /// 3. If allowCredentialDescriptorList is empty, return false. + /// 4. Let distinctTransports be a new ordered set. + /// 5. If allowCredentialDescriptorList has exactly one value, set savedCredentialIds[authenticator] to allowCredentialDescriptorList[0].id’s value (see here in § 6.3.3 The authenticatorGetAssertion Operation for more information). + /// 6. For each credential descriptor C in allowCredentialDescriptorList, append each value, if any, of C.transports to distinctTransports. + /// NOTE: This will aggregate only distinct values of transports (for this authenticator) in distinctTransports due to the properties of ordered sets. + /// 7. If distinctTransports + /// → is not empty + /// The client selects one transport value from distinctTransports, possibly incorporating local configuration knowledge of the appropriate transport to use with authenticator in making its selection. + /// Then, using transport, invoke the authenticatorGetAssertion operation on authenticator, with rpId, clientDataHash, allowCredentialDescriptorList, userVerification, and authenticatorExtensions as parameters. + /// → is empty + /// Using local configuration knowledge of the appropriate transport to use with authenticator, invoke the authenticatorGetAssertion operation on authenticator with rpId, clientDataHash, allowCredentialDescriptorList, userVerification, and authenticatorExtensions as parameters. + filteredCredentialDescriptors( + credentialDescriptors: allowCredentials, + relyingPartyID: authenticationRequest.options.relyingPartyID + ) + } else { + /// → is empty + /// Using local configuration knowledge of the appropriate transport to use with authenticator, invoke the authenticatorGetAssertion operation on authenticator with rpId, clientDataHash, userVerification, and authenticatorExtensions as parameters. + /// NOTE: In this case, the Relying Party did not supply a list of acceptable credential descriptors. Thus, the authenticator is being asked to exercise any credential it may possess that is scoped to the Relying Party, as identified by rpId. + [] + } + + /// Step 11. Return true. + // Skip. + + /// [WebAuthn Level 3 Editor's Draft §6.3.3. The authenticatorGetAssertion Operation](https://w3c.github.io/webauthn/#authenticatorgetassertion) + /// Step 1. Check if all the supplied parameters are syntactically well-formed and of the correct length. If not, return an error code equivalent to "UnknownError" and terminate the operation. + // Skip. + + /// Step 2. Let credentialOptions be a new empty set of public key credential sources. + /// Step 3. If allowCredentialDescriptorList was supplied, then for each descriptor of allowCredentialDescriptorList: + /// 1. Let credSource be the result of looking up descriptor.id in this authenticator. + /// 2. If credSource is not null, append it to credentialOptions. + /// Step 4. Otherwise (allowCredentialDescriptorList was not supplied), for each key → credSource of this authenticator’s credentials map, append credSource to credentialOptions. + var credentialOptions = if !allowedCredentialDescriptorList.isEmpty { + allowedCredentialDescriptorList.compactMap { credentialDescriptor -> CredentialSource? in + guard + credentialDescriptor.type == .publicKey, + let id = CredentialSource.ID(bytes: credentialDescriptor.id) + else { return nil } + + return credentials[id] + } + } else { + Array(credentials.values) + } + + /// Step 5. Remove any items from credentialOptions whose rpId is not equal to rpId. + credentialOptions.removeAll { $0.relyingPartyID != authenticationRequest.options.relyingPartyID } + + /// Step 6. If credentialOptions is now empty, return an error code equivalent to "NotAllowedError" and terminate the operation. + guard !credentialOptions.isEmpty + else { throw WebAuthnError.noCredentialsAvailable } + + /// Step 7. Prompt the user to select a public key credential source selectedCredential from credentialOptions. Collect an authorization gesture confirming user consent for using selectedCredential. The prompt for the authorization gesture may be shown by the authenticator if it has its own output capability, or by the user agent otherwise. + /// If requireUserVerification is true, the authorization gesture MUST include user verification. + /// If requireUserPresence is true, the authorization gesture MUST include a test of user presence. + /// If the user does not consent, return an error code equivalent to "NotAllowedError" and terminate the operation. + let selectedCredential = try await collectAuthorizationGesture( + requiresUserVerification: requestsUserVerification, + requiresUserPresence: true, // TODO: Make option + credentialOptions: credentialOptions + ) + + /// Step 8. Let processedExtensions be the result of authenticator extension processing for each supported extension identifier → authenticator extension input in extensions. + // Skip. + + /// Step 9. Increment the credential associated signature counter or the global signature counter value, depending on which approach is implemented by the authenticator, by some positive value. If the authenticator does not implement a signature counter, let the signature counter value remain constant at zero. + // Done already in Step 7. + + /// Step 10. Let authenticatorData be the byte array specified in § 6.1 Authenticator Data including processedExtensions, if any, as the extensions and excluding attestedCredentialData. + let authenticatorData = AuthenticatorData( + relyingPartyIDHash: SHA256.hash(data: Array(authenticationRequest.options.relyingPartyID.utf8)), + flags: AuthenticatorFlags( + userPresent: true, + userVerified: true, + isBackupEligible: true, + isCurrentlyBackedUp: true, + attestedCredentialData: false, + extensionDataIncluded: false + ), // TODO: Add first four flags to credential source/collection gesture + counter: 0 // TODO: Add to credential source requirement + ).bytes + + /// Step 11. Let signature be the assertion signature of the concatenation authenticatorData || hash using the privateKey of selectedCredential as shown in Figure , below. A simple, undelimited concatenation is safe to use here because the authenticator data describes its own length. The hash of the serialized client data (which potentially has a variable length) is always the last element. + /// Step 12. If any error occurred while generating the assertion signature, return an error code equivalent to "UnknownError" and terminate the operation. + let signature = try await selectedCredential.signAssertion( + authenticatorData: authenticatorData, + clientDataHash: authenticationRequest.clientDataHash + ) + + /// Step 13. Return to the user agent: + /// selectedCredential.id, if either a list of credentials (i.e., allowCredentialDescriptorList) of length 2 or greater was supplied by the client, or no such list was supplied. + /// NOTE: If, within allowCredentialDescriptorList, the client supplied exactly one credential and it was successfully employed, then its credential ID is not returned since the client already knows it. This saves transmitting these bytes over what may be a constrained connection in what is likely a common case. + /// authenticatorData + /// signature + /// selectedCredential.userHandle + /// NOTE: In cases where allowCredentialDescriptorList was supplied the returned userHandle value may be null, see: userHandleResult. + try await authenticationRequest.attemptAuthentication.submitAssertionResults( + credentialID: selectedCredential.id.bytes, + authenticatorData: authenticatorData, + signature: signature, + userHandle: selectedCredential.userHandle, + authenticatorAttachment: .platform // TODO: Make option + ) + + /// If the authenticator cannot find any credential corresponding to the specified Relying Party that matches the specified criteria, it terminates the operation and returns an error. + // Already done. + + return selectedCredential } } diff --git a/Sources/WebAuthn/WebAuthnError.swift b/Sources/WebAuthn/WebAuthnError.swift index 24b83f62..467ac16e 100644 --- a/Sources/WebAuthn/WebAuthnError.swift +++ b/Sources/WebAuthn/WebAuthnError.swift @@ -66,13 +66,15 @@ public struct WebAuthnError: Error, Hashable, Sendable { case invalidExponent case unsupportedCOSEAlgorithmForRSAPublicKey case unsupported - + // MARK: WebAuthnClient case noSupportedCredentialParameters case missingCredentialSourceDespiteSuccess // MARK: Authenticator case unsupportedCredentialPublicKeyType + case requiredUserVerificationNotSupported + case noCredentialsAvailable case authorizationGestureNotAllowed } @@ -140,5 +142,7 @@ public struct WebAuthnError: Error, Hashable, Sendable { // MARK: Authenticator public static let unsupportedCredentialPublicKeyType = Self(reason: .unsupportedCredentialPublicKeyType) + public static let requiredUserVerificationNotSupported = Self(reason: .requiredUserVerificationNotSupported) + public static let noCredentialsAvailable = Self(reason: .noCredentialsAvailable) public static let authorizationGestureNotAllowed = Self(reason: .authorizationGestureNotAllowed) } From f21ce6ff4dadcb4c404dae58008e07ef72bbb6f3 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Wed, 28 Feb 2024 03:29:57 -0800 Subject: [PATCH 28/29] Added client registration and authentication integration tests --- .../WebAuthnManagerIntegrationTests.swift | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift b/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift index 80cf702a..73d95622 100644 --- a/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift +++ b/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift @@ -160,4 +160,142 @@ final class WebAuthnManagerIntegrationTests: XCTestCase { // We did it! } + + func testClientRegistrationAndAuthentication() async throws { + let challenge: [UInt8] = [1, 0, 1] + let relyingPartyDisplayName = "Testy test" + let relyingPartyID = "example.com" + let relyingPartyOrigin = "https://example.com" + + let server = WebAuthnManager( + configuration: .init( + relyingPartyID: relyingPartyID, + relyingPartyName: relyingPartyDisplayName, + relyingPartyOrigin: relyingPartyOrigin + ), + challengeGenerator: .mock(generate: challenge) + ) + + let client = WebAuthnClient() + let aaguid = AAGUID(uuid: UUID()) + let authenticator = KeyPairAuthenticator(attestationGloballyUniqueID: aaguid) + + let credentialCreationOptions = server.beginRegistration(user: .init(id: [1, 2, 3], name: "123", displayName: "One Two Three")) + + let (registrationCredential, credentialSource) = try await client.createRegistrationCredential( + options: credentialCreationOptions, + origin: relyingPartyOrigin, + authenticator: authenticator + ) + + XCTAssertEqual(registrationCredential.type, .publicKey) + XCTAssertEqual(registrationCredential.rawID.count, 16) + XCTAssertEqual(registrationCredential.id, registrationCredential.rawID.base64URLEncodedString()) + + let parsedAttestationResponse = try ParsedAuthenticatorAttestationResponse(from: registrationCredential.attestationResponse) + XCTAssertEqual(parsedAttestationResponse.clientData.type, .create) + XCTAssertEqual(parsedAttestationResponse.clientData.challenge.decodedBytes, [1, 0, 1]) + XCTAssertEqual(parsedAttestationResponse.clientData.origin, "https://example.com") + + XCTAssertEqual(parsedAttestationResponse.attestationObject.authenticatorData.relyingPartyIDHash, [163, 121, 166, 246, 238, 175, 185, 165, 94, 55, 140, 17, 128, 52, 226, 117, 30, 104, 47, 171, 159, 45, 48, 171, 19, 210, 18, 85, 134, 206, 25, 71]) + XCTAssertEqual(parsedAttestationResponse.attestationObject.authenticatorData.flags.bytes, [0b01011101]) + XCTAssertEqual(parsedAttestationResponse.attestationObject.authenticatorData.counter, 0) + XCTAssertNotNil(parsedAttestationResponse.attestationObject.authenticatorData.attestedData) + XCTAssertEqual(parsedAttestationResponse.attestationObject.authenticatorData.attestedData?.authenticatorAttestationGUID, AAGUID.anonymous) + XCTAssertEqual(parsedAttestationResponse.attestationObject.authenticatorData.attestedData?.credentialID, credentialSource.id.bytes) + XCTAssertEqual(parsedAttestationResponse.attestationObject.authenticatorData.extData, nil) + + let publicKey = try CredentialPublicKey(publicKeyBytes: parsedAttestationResponse.attestationObject.authenticatorData.attestedData?.publicKey ?? []) + if case .ec2(let key) = publicKey { + XCTAssertEqual(key.algorithm, .algES256) + XCTAssertEqual(key.curve, .p256) + XCTAssertEqual(key.xCoordinate.count, 32) + XCTAssertEqual(key.yCoordinate.count, 32) + XCTAssertEqual(key.rawRepresentation, (credentialSource.publicKey as? EC2PublicKey)?.rawRepresentation) + } else { + XCTFail("Unexpected publicKey format") + } + + XCTAssertEqual(parsedAttestationResponse.attestationObject.format, .none) + XCTAssertEqual(parsedAttestationResponse.attestationObject.attestationStatement, [:]) + + XCTAssertEqual(credentialSource.relyingPartyID, "example.com") + XCTAssertEqual(credentialSource.userHandle, [1, 2, 3]) + XCTAssertEqual(credentialSource.counter, 0) + if case .es256(let privateKey) = credentialSource.key { + XCTAssertEqual(Array(privateKey.publicKey.rawRepresentation), (credentialSource.publicKey as? EC2PublicKey)?.rawRepresentation) + } else { + XCTFail("Unexpected credentialSource.key format") + } + + let registeredCredential = try await server.finishRegistration( + challenge: challenge, + credentialCreationData: registrationCredential + ) { credentialID in + XCTAssertEqual(credentialID, credentialSource.id.bytes.base64URLEncodedString().asString()) + return true + } + + XCTAssertEqual(registeredCredential.type, .publicKey) + XCTAssertEqual(registeredCredential.id, credentialSource.id.bytes.base64EncodedString().asString()) + XCTAssertEqual(registeredCredential.publicKey, (credentialSource.publicKey as? EC2PublicKey)?.bytes) + XCTAssertEqual(registeredCredential.signCount, 0) + XCTAssertEqual(registeredCredential.backupEligible, true) + XCTAssertEqual(registeredCredential.isBackedUp, true) + + let credentialRequestOptions = try server.beginAuthentication() + + XCTAssertEqual(credentialRequestOptions.challenge, [1, 0, 1]) + XCTAssertEqual(credentialRequestOptions.timeout, .milliseconds(60000)) + XCTAssertEqual(credentialRequestOptions.relyingPartyID, "example.com") + XCTAssertNil(credentialRequestOptions.allowCredentials) + XCTAssertEqual(credentialRequestOptions.userVerification, .preferred) + + let (authenticationCredential, updatedCredentialSource) = try await client.assertAuthenticationCredential( + options: credentialRequestOptions, + origin: relyingPartyOrigin, + authenticator: authenticator, + credentialStore: [credentialSource.id : credentialSource] + ) + + XCTAssertEqual(authenticationCredential.type, .publicKey) + XCTAssertEqual(authenticationCredential.rawID.count, 16) + XCTAssertEqual(authenticationCredential.id, authenticationCredential.rawID.base64URLEncodedString()) + XCTAssertEqual(authenticationCredential.authenticatorAttachment, .platform) + + let parsedAssertionResponse = try ParsedAuthenticatorAssertionResponse(from: authenticationCredential.response) + XCTAssertEqual(parsedAssertionResponse.clientData.type, .assert) + XCTAssertEqual(parsedAssertionResponse.clientData.challenge.decodedBytes, [1, 0, 1]) + XCTAssertEqual(parsedAssertionResponse.clientData.origin, "https://example.com") + + XCTAssertEqual(parsedAssertionResponse.authenticatorData.relyingPartyIDHash, [163, 121, 166, 246, 238, 175, 185, 165, 94, 55, 140, 17, 128, 52, 226, 117, 30, 104, 47, 171, 159, 45, 48, 171, 19, 210, 18, 85, 134, 206, 25, 71]) + XCTAssertEqual(parsedAssertionResponse.authenticatorData.flags.bytes, [0b00011101]) + XCTAssertEqual(parsedAssertionResponse.authenticatorData.counter, 0) + XCTAssertNil(parsedAssertionResponse.authenticatorData.attestedData) + XCTAssertNil(parsedAssertionResponse.authenticatorData.extData) + + XCTAssertNotNil(parsedAssertionResponse.signature.decodedBytes) + XCTAssertEqual(parsedAssertionResponse.userHandle, [1, 2, 3]) + + XCTAssertEqual(credentialSource.id, updatedCredentialSource.id) + XCTAssertEqual(updatedCredentialSource.relyingPartyID, "example.com") + XCTAssertEqual(updatedCredentialSource.userHandle, [1, 2, 3]) + XCTAssertEqual(updatedCredentialSource.counter, 0) + if case .es256(let privateKey) = updatedCredentialSource.key { + XCTAssertEqual(Array(privateKey.publicKey.rawRepresentation), (updatedCredentialSource.publicKey as? EC2PublicKey)?.rawRepresentation) + } else { + XCTFail("Unexpected credentialSource.key format") + } + + let verifiedAuthentication = try server.finishAuthentication( + credential: authenticationCredential, + expectedChallenge: challenge, + credentialPublicKey: registeredCredential.publicKey, credentialCurrentSignCount: registeredCredential.signCount + ) + + XCTAssertEqual(verifiedAuthentication.credentialID.urlDecoded.asString(), registeredCredential.id) + XCTAssertEqual(verifiedAuthentication.newSignCount, 0) + XCTAssertEqual(verifiedAuthentication.credentialDeviceType, .multiDevice) + XCTAssertEqual(verifiedAuthentication.credentialBackedUp, true) + } } From 817b02e287110a8077bb16f5eef19fd90923b357 Mon Sep 17 00:00:00 2001 From: Dimitri Bouniol Date: Tue, 11 Jun 2024 05:33:51 -0700 Subject: [PATCH 29/29] Updated client to use task groups instead of custom cancellable child tasks --- .../AssertionAuthenticationRequest.swift | 47 ++-- .../AttestationRegistrationRequest.swift | 29 +- .../Protocol/AuthenticatorProtocol.swift | 49 +++- .../Registration/AttestationObject.swift | 2 +- .../Helpers/CancellableContinuationTask.swift | 92 ------- Sources/WebAuthn/WebAuthnClient.swift | 255 ++++++++++-------- Sources/WebAuthn/WebAuthnError.swift | 2 + 7 files changed, 197 insertions(+), 279 deletions(-) delete mode 100644 Sources/WebAuthn/Helpers/CancellableContinuationTask.swift diff --git a/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift b/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift index f6c0eaaf..ae87e971 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AssertionAuthenticationRequest.swift @@ -17,53 +17,36 @@ public struct AssertionAuthenticationRequest: Sendable { public var options: PublicKeyCredentialRequestOptions public var clientDataHash: SHA256Digest - public var attemptAuthentication: Callback init( options: PublicKeyCredentialRequestOptions, - clientDataHash: SHA256Digest, - attemptAuthentication: @Sendable @escaping (_ assertionResults: Results) async throws -> () + clientDataHash: SHA256Digest ) { self.options = options self.clientDataHash = clientDataHash - self.attemptAuthentication = Callback(callback: attemptAuthentication) } } extension AssertionAuthenticationRequest { - public struct Callback: Sendable { - /// The internal callback the attestation should call. - var callback: @Sendable (_ assertionResults: Results) async throws -> () + public struct Results: Sendable { + public var credentialID: [UInt8] + public var authenticatorData: [UInt8] + public var signature: [UInt8] + public var userHandle: [UInt8]? + public var authenticatorAttachment: AuthenticatorAttachment - /// Submit the results of asserting a user's authentication request. - /// - /// Authenticators should call this to submit a successful authentication and cancel any other pending authenticators. - /// - /// - SeeAlso: https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object - public func submitAssertionResults( + public init( credentialID: [UInt8], authenticatorData: [UInt8], signature: [UInt8], - userHandle: [UInt8]?, + userHandle: [UInt8]? = nil, authenticatorAttachment: AuthenticatorAttachment - ) async throws { - try await callback(Results( - credentialID: credentialID, - authenticatorData: authenticatorData, - signature: signature, - userHandle: userHandle, - authenticatorAttachment: authenticatorAttachment - )) + ) { + self.credentialID = credentialID + self.authenticatorData = authenticatorData + self.signature = signature + self.userHandle = userHandle + self.authenticatorAttachment = authenticatorAttachment } } } - -extension AssertionAuthenticationRequest { - struct Results { - var credentialID: [UInt8] - var authenticatorData: [UInt8] - var signature: [UInt8] - var userHandle: [UInt8]? - var authenticatorAttachment: AuthenticatorAttachment - } -} diff --git a/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift b/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift index a53c23bf..3ebe6ea6 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AttestationRegistrationRequest.swift @@ -19,41 +19,14 @@ public struct AttestationRegistrationRequest: Sendable { var options: PublicKeyCredentialCreationOptions var publicKeyCredentialParameters: [PublicKeyCredentialParameters] var clientDataHash: SHA256Digest - var attemptRegistration: Callback init( options: PublicKeyCredentialCreationOptions, publicKeyCredentialParameters: [PublicKeyCredentialParameters], - clientDataHash: SHA256Digest, - attemptRegistration: @Sendable @escaping (_ attestationObject: AttestationObject) async throws -> () + clientDataHash: SHA256Digest ) { self.options = options self.publicKeyCredentialParameters = publicKeyCredentialParameters self.clientDataHash = clientDataHash - self.attemptRegistration = Callback(callback: attemptRegistration) - } -} - -extension AttestationRegistrationRequest { - public struct Callback: Sendable { - /// The internal callback the attestation should call. - var callback: @Sendable (_ attestationObject: AttestationObject) async throws -> () - - /// Generate an attestation object for registration and submit it. - /// - /// Authenticators should call this to submit a successful registration and cancel any other pending authenticators. - /// - /// - SeeAlso: https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object - public func submitAttestationObject( - attestationFormat: AttestationFormat, - authenticatorData: AuthenticatorData, - attestationStatement: CBOR - ) async throws { - try await callback(AttestationObject( - authenticatorData: authenticatorData, - format: attestationFormat, - attestationStatement: attestationStatement - )) - } } } diff --git a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift index beaca064..05138f32 100644 --- a/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift +++ b/Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift @@ -17,7 +17,34 @@ import SwiftCBOR public typealias CredentialStore = [A.CredentialSource.ID : A.CredentialSource] -public protocol AuthenticatorProtocol { + +public protocol AuthenticatorRegistrationConsumer: Sendable { + associatedtype CredentialOutput: Sendable + + /// Generate an attestation object for registration and submit it. + /// + /// Authenticators should call this to submit a successful registration and cancel any other pending authenticators. + /// + /// - SeeAlso: https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object + func makeCredentials(with registration: AttestationRegistrationRequest) async throws -> (AttestationObject, CredentialOutput) +} + +public protocol AuthenticatorAssertionConsumer: Sendable { + associatedtype CredentialInput: Sendable + associatedtype CredentialOutput: Sendable + + /// Submit the results of asserting a user's authentication request. + /// + /// Authenticators should call this to submit a successful authentication and cancel any other pending authenticators. + /// + /// - SeeAlso: https://w3c.github.io/webauthn/#sctn-generating-an-attestation-object + func assertCredentials( + authenticationRequest: AssertionAuthenticationRequest, + credentials: CredentialInput + ) async throws -> (AssertionAuthenticationRequest.Results, CredentialOutput) +} + +public protocol AuthenticatorProtocol: AuthenticatorRegistrationConsumer, AuthenticatorAssertionConsumer { associatedtype CredentialSource: AuthenticatorCredentialSourceProtocol var attestationGloballyUniqueID: AAGUID { get } @@ -62,10 +89,10 @@ public protocol AuthenticatorProtocol { /// Make credentials for the specified registration request, returning the credential source that the caller should store for subsequent authentication. /// - /// - Important: Depending on the authenticator being used, the credential source may contain private keys, and must be stored sequirely, such as in the user's Keychain, or in a Hardware Security Module appropriate with the level of security you wish to secure your user's account with. + /// - Important: Depending on the authenticator being used, the credential source may contain private keys, and must be stored securely, such as in the user's Keychain, or in a Hardware Security Module appropriate with the level of security you wish to secure your user's account with. /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §5.1.3. Create a New Credential - PublicKeyCredential’s Create(origin, options, sameOriginWithAncestors) Method, Step 25.]( https://w3c.github.io/webauthn/#CreateCred-async-loop) /// - SeeAlso: [WebAuthn Level 3 Editor's Draft §6.3.2. The authenticatorMakeCredential Operation](https://w3c.github.io/webauthn/#sctn-op-make-cred) - func makeCredentials(with registration: AttestationRegistrationRequest) async throws -> CredentialSource + func makeCredentials(with registration: AttestationRegistrationRequest) async throws -> (AttestationObject, CredentialSource) /// Filter the provided credential descriptors to determine which, if any, should be handled by this authenticator. /// @@ -106,7 +133,7 @@ public protocol AuthenticatorProtocol { func assertCredentials( authenticationRequest: AssertionAuthenticationRequest, credentials: CredentialStore - ) async throws -> CredentialSource + ) async throws -> (AssertionAuthenticationRequest.Results, CredentialSource) } // MARK: - Default Implementations @@ -142,7 +169,7 @@ extension AuthenticatorProtocol { extension AuthenticatorProtocol { public func makeCredentials( with registration: AttestationRegistrationRequest - ) async throws -> CredentialSource { + ) async throws -> (AttestationObject, CredentialSource) { /// See [WebAuthn Level 3 Editor's Draft §5.1.3. Create a New Credential - PublicKeyCredential’s Create(origin, options, sameOriginWithAncestors) Method, Step 25.]( https://w3c.github.io/webauthn/#CreateCred-async-loop) /// Step 1. This authenticator is now the candidate authenticator. /// Step 2. If pkOptions.authenticatorSelection is present: @@ -328,13 +355,13 @@ extension AuthenticatorProtocol { ) /// On successful completion of this operation, the authenticator returns the attestation object to the client. - try await registration.attemptRegistration.submitAttestationObject( - attestationFormat: attestationFormat, + let attestationObject = AttestationObject( authenticatorData: authenticatorData, + format: attestationFormat, attestationStatement: attestationStatement ) - return credentialSource + return (attestationObject, credentialSource) } } @@ -344,7 +371,7 @@ extension AuthenticatorProtocol { public func assertCredentials( authenticationRequest: AssertionAuthenticationRequest, credentials: CredentialStore - ) async throws -> CredentialSource { + ) async throws -> (AssertionAuthenticationRequest.Results, CredentialSource) { /// [WebAuthn Level 3 Editor's Draft §5.1.4.2. Issuing a Credential Request to an Authenticator](https://w3c.github.io/webauthn/#sctn-issuing-cred-request-to-authenticator) /// Step 1. If pkOptions.userVerification is set to required and the authenticator is not capable of performing user verification, return false. if authenticationRequest.options.userVerification == .required && !canPerformUserVerification { @@ -473,7 +500,7 @@ extension AuthenticatorProtocol { /// signature /// selectedCredential.userHandle /// NOTE: In cases where allowCredentialDescriptorList was supplied the returned userHandle value may be null, see: userHandleResult. - try await authenticationRequest.attemptAuthentication.submitAssertionResults( + let assertionResults = AssertionAuthenticationRequest.Results( credentialID: selectedCredential.id.bytes, authenticatorData: authenticatorData, signature: signature, @@ -484,6 +511,6 @@ extension AuthenticatorProtocol { /// If the authenticator cannot find any credential corresponding to the specified Relying Party that matches the specified criteria, it terminates the operation and returns an error. // Already done. - return selectedCredential + return (assertionResults, selectedCredential) } } diff --git a/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift b/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift index a4d7ed65..67ff24c4 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AttestationObject.swift @@ -35,7 +35,7 @@ public struct AttestationObject: Sendable { self.attestationStatement = attestationStatement } - init( + public init( authenticatorData: AuthenticatorData, format: AttestationFormat, attestationStatement: CBOR diff --git a/Sources/WebAuthn/Helpers/CancellableContinuationTask.swift b/Sources/WebAuthn/Helpers/CancellableContinuationTask.swift deleted file mode 100644 index b250da86..00000000 --- a/Sources/WebAuthn/Helpers/CancellableContinuationTask.swift +++ /dev/null @@ -1,92 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the WebAuthn Swift open source project -// -// Copyright (c) 2023 the WebAuthn Swift project authors -// Licensed under Apache License v2.0 -// -// See LICENSE.txt for license information -// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors -// -// SPDX-License-Identifier: Apache-2.0 -// -//===----------------------------------------------------------------------===// - -/// An internal type to assist kicking off work without primarily awaiting it, instead allowing that work to call into a continuation as needed. -/// Use ``withCancellableFirstSuccessfulContinuation()`` instead of invoking this directly. -actor CancellableContinuation: Sendable { - private var bodyTask: Task? - private var continuation: CheckedContinuation? - private var isCancelled = false - - private func cancelMainTask() { - continuation?.resume(throwing: CancellationError()) - continuation = nil - bodyTask?.cancel() - isCancelled = true - } - - private func isolatedResume(returning value: T) { - continuation?.resume(returning: value) - continuation = nil - cancelMainTask() - } - - nonisolated func cancel() { - Task { await cancelMainTask() } - } - - nonisolated func resume(returning value: T) { - Task { await isolatedResume(returning: value) } - } - - /// Wrap an asynchronous closure providing a continuation for when results are ready that can be called any number of times, but also allowing the closure to be cancelled at any time, including once the first successful value is provided. - fileprivate func wrap(_ body: Body) async throws -> T { - assert(bodyTask == nil, "A CancellableContinuationTask should only be used once.") - /// Register a cancellation callback that will: a) immediately cancel the continuation if we have one, b) unset it so it doesn't get called a second time, and c) cancel the main task. - return try await withTaskCancellationHandler { - let response: T = try await withCheckedThrowingContinuation { localContinuation in - /// Synchronously a) check if we've been cancelled, stopping early, b) save the contnuation, and c) assign the task, which runs immediately. - /// This works since we are guaranteed to hear back from the cancellation handler either immediately, since Task.isCancelled is already set, or after task is set, since we are executing on the actor's executor. - guard !Task.isCancelled else { - localContinuation.resume(throwing: CancellationError()) - return - } - - self.continuation = localContinuation - self.bodyTask = Task { [unowned self] in - /// If the continuation doesn't exist at this point, it's because we've already been cancelled. This is guaranteed to run after the task has been set and potentially cancelled since it also runs on the task executor. - guard let continuation = self.continuation else { return } - do { - try await body(self) - } catch { - /// If the main body fails for any reason, pass along the error. This will be a no-op if the continuation was already resumed or cancelled. - continuation.resume(throwing: error) - self.continuation = nil - } - } - } - /// Wait for the body to finish cancelling before continuing, so it doesn't run into any data races. - try? await bodyTask?.value - return response - } onCancel: { - cancel() - } - } - - /// A wrapper for the body, which will ever only be called once, in a non-escaping manner before the continuation resumes. - fileprivate struct Body: @unchecked Sendable { - var body: (_ continuation: CancellableContinuation) async throws -> () - - func callAsFunction(_ continuation: CancellableContinuation) async throws { - try await body(continuation) - } - } -} - -/// Execute an operation providing it a continuation for when results are ready that can be called any number of times, but also allowing the operation to be cancelled at any time, including once the first successful value is provided. -func withCancellableFirstSuccessfulContinuation(_ body: (_ continuation: CancellableContinuation) async throws -> ()) async throws -> T { - try await withoutActuallyEscaping(body) { escapingBody in - try await CancellableContinuation().wrap(.init { try await escapingBody($0) }) - } -} diff --git a/Sources/WebAuthn/WebAuthnClient.swift b/Sources/WebAuthn/WebAuthnClient.swift index 0220bd95..5c5306eb 100644 --- a/Sources/WebAuthn/WebAuthnClient.swift +++ b/Sources/WebAuthn/WebAuthnClient.swift @@ -13,7 +13,7 @@ //===----------------------------------------------------------------------===// import Foundation -import Crypto +@preconcurrency import Crypto /// A client implementation capable of interfacing between an ``AuthenticatorProtocol`` authenticator and the Web Authentication API. /// @@ -25,15 +25,18 @@ import Crypto public struct WebAuthnClient { public init() {} - public func createRegistrationCredential( + public func createRegistrationCredential( options: PublicKeyCredentialCreationOptions, /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout minTimeout: Duration = .seconds(300), maxTimeout: Duration = .seconds(600), origin: String, supportedPublicKeyCredentialParameters: Set = .supported, - attestRegistration: (_ registration: AttestationRegistrationRequest) async throws -> () - ) async throws -> RegistrationCredential { + authenticator: Authenticator + ) async throws -> ( + registrationCredential: RegistrationCredential, + credentialSource: Authenticator.CredentialOutput + ) { /// Steps: https://w3c.github.io/webauthn/#sctn-createCredential /// Step 1. Assert: options.publicKey is present. @@ -154,13 +157,17 @@ public struct WebAuthnClient { /// Step 25. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: do { /// Let the caller do what it needs to do to coordinate with authenticators, so long as at least one of them calls the attestation callback. - var attestationObjectResult: AttestationObject = try await withCancellableFirstSuccessfulContinuation { [attestRegistration, publicKeyCredentialParameters] continuation in + var (attestationObjectResult, credentialOutput) = try await withThrowingTaskGroup(of: (AttestationObject, Authenticator.CredentialOutput).self) { [publicKeyCredentialParameters, clientDataHash] group in /// → If lifetimeTimer expires, /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. - Task { + group.addTask(priority: .high) { /// Let the timer run in the background to cancel the continuation if it runs over. - await timeoutTask.value - continuation.cancel() // TODO: Should be a timeout error + await withTaskCancellationHandler { + await timeoutTask.value + } onCancel: { + timeoutTask.cancel() + } + throw WebAuthnError.timeoutError } /// → If the user exercises a user agent user-interface option to cancel the process, @@ -192,13 +199,21 @@ public struct WebAuthnClient { /// NOTE: This case does not imply user consent for the operation, so details about the error are hidden from the Relying Party in order to prevent leak of potentially identifying information. See § 14.5.1 Registration Ceremony Privacy for details. /// Kick off the attestation process, waiting for one to succeed before the timeout. - try await attestRegistration(AttestationRegistrationRequest( + let registrationRequest = AttestationRegistrationRequest( options: options, publicKeyCredentialParameters: publicKeyCredentialParameters, clientDataHash: clientDataHash - ) { attestationObject in - continuation.resume(returning: attestationObject) - }) + ) + group.addTask { + try await authenticator.makeCredentials(with: registrationRequest) + } + + /// The first results will always have the attestation object and credential output ready, or will throw on error, cancellation, or timeout. + /// If a timeout occurs, the actual work will be cancelled, though progress cannot move forwards until it actually wraps up its work. + guard let results = try await group.next() + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } + group.cancelAll() + return results } /// → If any authenticator indicates success, @@ -274,7 +289,7 @@ public struct WebAuthnClient { // Already performed. /// 5. Return constructCredentialAlg and terminate this algorithm. - return publicKeyCredential + return (publicKeyCredential, credentialOutput) } catch { /// Step 35. Throw a "NotAllowedError" DOMException. In order to prevent information leak that could identify the user without consent, this step MUST NOT be executed before lifetimeTimer has expired. See § 14.5.1 Registration Ceremony Privacy for details. /// During the above process, the user agent SHOULD show some UI to the user to guide them in the process of selecting and authorizing an authenticator. @@ -290,15 +305,19 @@ public struct WebAuthnClient { } } - public func assertAuthenticationCredential( + public func assertAuthenticationCredential( options: PublicKeyCredentialRequestOptions, /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout minTimeout: Duration = .seconds(300), maxTimeout: Duration = .seconds(600), origin: String, // mediation: , - assertAuthentication: (_ authentication: AssertionAuthenticationRequest) async throws -> () - ) async throws -> AuthenticationCredential { + authenticator: Authenticator, + credentialStore: Authenticator.CredentialInput + ) async throws -> ( + authenticationCredential: AuthenticationCredential, + updatedCredentialSource: Authenticator.CredentialOutput + ) { /// See https://w3c.github.io/webauthn/#sctn-discover-from-external-source /// Step 1. Assert: options.publicKey is present. // Skip, already is. @@ -399,13 +418,17 @@ public struct WebAuthnClient { /// Step 20. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators: do { /// Let the caller do what it needs to do to coordinate with authenticators, so long as at least one of them calls the assertion callback. - let assertionResults: AssertionAuthenticationRequest.Results = try await withCancellableFirstSuccessfulContinuation { [assertAuthentication] continuation in + let (assertionResults, credentialOutput) = try await withThrowingTaskGroup(of: (AssertionAuthenticationRequest.Results, Authenticator.CredentialOutput).self) { group in /// → If lifetimeTimer expires, /// For each authenticator in issuedRequests invoke the authenticatorCancel operation on authenticator and remove authenticator from issuedRequests. - Task { + group.addTask(priority: .high) { /// Let the timer run in the background to cancel the continuation if it runs over. - await timeoutTask.value - continuation.cancel() // TODO: Should be a timeout error + await withTaskCancellationHandler { + await timeoutTask.value + } onCancel: { + timeoutTask.cancel() + } + throw WebAuthnError.timeoutError } /// → If the user exercises a user agent user-interface option to cancel the process, @@ -451,13 +474,21 @@ public struct WebAuthnClient { /// If this returns false, continue. /// NOTE: This branch is taken if options.mediation is conditional and the authenticator does not support the silentCredentialDiscovery operation to allow use of such authenticators during a conditional user mediation request. /// 2. Append authenticator to issuedRequests. - try await assertAuthentication(AssertionAuthenticationRequest( + + let authenticationRequest = AssertionAuthenticationRequest( options: options, - clientDataHash: clientDataHash, - attemptAuthentication: { assertionResults in - continuation.resume(returning: assertionResults) - } - )) + clientDataHash: clientDataHash + ) + group.addTask { + try await authenticator.assertCredentials(authenticationRequest: authenticationRequest, credentials: credentialStore) + } + + /// The first results will always have the assertion results and credential output ready, or will throw on error, cancellation, or timeout. + /// If a timeout occurs, the actual work will be cancelled, though progress cannot move forwards until it actually wraps up its work. + guard let results = try await group.next() + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } + group.cancelAll() + return results } /// → If an authenticator ceases to be available on this client device, @@ -534,7 +565,7 @@ public struct WebAuthnClient { // Already performed. /// 7. Return constructAssertionAlg and terminate this algorithm. - return publicKeyCredential + return (publicKeyCredential, credentialOutput) } catch { /// Step 31. Throw a "NotAllowedError" DOMException. In order to prevent information leak that could identify the user without consent, this step MUST NOT be executed before lifetimeTimer has expired. See § 14.5.2 Authentication Ceremony Privacy for details. await withTaskCancellationHandler { @@ -550,118 +581,111 @@ public struct WebAuthnClient { } } -// MARK: Convenience Registration and Authentication +/* +// MARK: Registration and Authentication With Multiple Authenticators -extension WebAuthnClient { - @inlinable - public func createRegistrationCredential( - options: PublicKeyCredentialCreationOptions, - /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout - minTimeout: Duration = .seconds(300), - maxTimeout: Duration = .seconds(600), - origin: String, - supportedPublicKeyCredentialParameters: Set = .supported, - authenticator: Authenticator - ) async throws -> (registrationCredential: RegistrationCredential, credentialSource: Authenticator.CredentialSource) { - var credentialSource: Authenticator.CredentialSource? - let registrationCredential = try await createRegistrationCredential( - options: options, - minTimeout: minTimeout, - maxTimeout: maxTimeout, - origin: origin, - supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters - ) { registration in - credentialSource = try await authenticator.makeCredentials(with: registration) - } - - guard let credentialSource - else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } - - return (registrationCredential, credentialSource) +/// Internal type to represent a group of authenticators as a single authenticator. +@available(macOS 14.0.0, *) +@usableFromInline +struct AuthenticatorRegistrationGroup: AuthenticatorRegistrationConsumer { + let authenticators: (repeat each Authenticator) + + @usableFromInline + init(authenticators: repeat each Authenticator) { + self.authenticators = (repeat each authenticators) } - @inlinable - public func createRegistrationCredential( - options: PublicKeyCredentialCreationOptions, - /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout - minTimeout: Duration = .seconds(300), - maxTimeout: Duration = .seconds(600), - origin: String, - supportedPublicKeyCredentialParameters: Set = .supported, - authenticators: repeat each Authenticator - ) async throws -> ( - registrationCredential: RegistrationCredential, - credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>) - ) { - /// Wrapper function since `repeat` doesn't currently support complex expressions - @Sendable func register( - authenticator: LocalAuthenticator, - registration: AttestationRegistrationRequest - ) -> Task { - Task { try await authenticator.makeCredentials(with: registration) } - } - - var credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>)? - let registrationCredential = try await createRegistrationCredential( - options: options, - minTimeout: minTimeout, - maxTimeout: maxTimeout, - origin: origin, - supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters - ) { registration in - /// Run each authenticator in parallel as child tasks, so we can automatically propagate cancellation to each of them should it occur. - let tasks = (repeat register( + @usableFromInline + func makeCredentials(with registration: AttestationRegistrationRequest) async throws -> (AttestationObject, (repeat Result<(each Authenticator).CredentialOutput, Error>)) { + var parentTask: Task<(AttestationObject, (repeat Result<(each Authenticator).CredentialOutput, Error>)), Error>! + parentTask = Task { + let tasks = (repeat makeCredentials( authenticator: each authenticators, - registration: registration + registration: registration, + parentTask: parentTask )) - await withTaskCancellationHandler { - credentialSources = (repeat await (each tasks).result) + + return try await withTaskCancellationHandler { + var sharedAttestationObject: AttestationObject? = nil + let results = (repeat await (each tasks).result) + let credentials = (repeat groupResult(result: each results, sharedAttestationObject: &sharedAttestationObject)) + + guard let sharedAttestationObject + else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } + + return (sharedAttestationObject, (repeat each credentials)) } onCancel: { repeat (each tasks).cancel() } } - - guard let credentialSources - else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } - - return (registrationCredential, credentialSources) + return try await withTaskCancellationHandler { + try await parentTask.value + } onCancel: { [parentTask] in + parentTask!.cancel() + } + } + + /// Wrapper function since `repeat` doesn't currently support complex expressions + func makeCredentials( + authenticator: LocalAuthenticator, + registration: AttestationRegistrationRequest, + parentTask: Task<(AttestationObject, (repeat Result<(each Authenticator).CredentialOutput, Error>)), Error> + ) -> Task<(attestationObject: AttestationObject, credentialOutput: LocalAuthenticator.CredentialOutput), Error> { + Task { + let result = try await authenticator.makeCredentials(with: registration) + parentTask.cancel() + return result + } } + /// Wrapper function since `repeat` doesn't currently support complex expressions + func groupResult( + result: Result<(attestationObject: AttestationObject, credentialOutput: T), Error>, + sharedAttestationObject: inout AttestationObject? + ) -> Result { + switch result { + case .success(let success): + if sharedAttestationObject == nil { + sharedAttestationObject = success.attestationObject + return .success(success.credentialOutput) + } else { + return .failure(CancellationError()) + } + case .failure(let failure): + return .failure(failure) + } + } +} + +extension WebAuthnClient { + @available(macOS 14.0.0, *) @inlinable - public func assertAuthenticationCredential( - options: PublicKeyCredentialRequestOptions, + public func createRegistrationCredential( + options: PublicKeyCredentialCreationOptions, /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout minTimeout: Duration = .seconds(300), maxTimeout: Duration = .seconds(600), origin: String, -// mediation: , - authenticator: Authenticator, - credentialStore: CredentialStore + supportedPublicKeyCredentialParameters: Set = .supported, + authenticators: repeat each Authenticator ) async throws -> ( - authenticationCredential: AuthenticationCredential, - updatedCredentialSource: Authenticator.CredentialSource + registrationCredential: RegistrationCredential, + credentialSources: (repeat Result<(each Authenticator).CredentialOutput, Error>) ) { - var credentialSource: Authenticator.CredentialSource? - let authenticationCredential = try await assertAuthenticationCredential( + let result = try await createRegistrationCredential( options: options, minTimeout: minTimeout, maxTimeout: maxTimeout, - origin: origin - ) { authentication in - credentialSource = try await authenticator.assertCredentials( - authenticationRequest: authentication, - credentials: credentialStore - ) - } - - guard let credentialSource - else { throw WebAuthnError.missingCredentialSourceDespiteSuccess } - - return (authenticationCredential, credentialSource) + origin: origin, + supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters, + authenticator: AuthenticatorRegistrationGroup(authenticators: repeat each authenticators) + ) + /// Need to rebuild the return value due to: `Cannot convert return expression of type '(registrationCredential: RegistrationCredential, credentialSource: AuthenticatorGroup.CredentialOutput)' to return type '(registrationCredential: RegistrationCredential, credentialSources: (repeat Result<(each Authenticator).CredentialOutput, any Error>))'` + return (result.registrationCredential, result.credentialSource) } @inlinable - public func assertAuthenticationCredential( + public func assertAuthenticationCredential( options: PublicKeyCredentialRequestOptions, /// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout minTimeout: Duration = .seconds(300), @@ -714,3 +738,4 @@ extension WebAuthnClient { return (authenticationCredential, credentialSources) } } +*/ diff --git a/Sources/WebAuthn/WebAuthnError.swift b/Sources/WebAuthn/WebAuthnError.swift index 467ac16e..45500369 100644 --- a/Sources/WebAuthn/WebAuthnError.swift +++ b/Sources/WebAuthn/WebAuthnError.swift @@ -70,6 +70,7 @@ public struct WebAuthnError: Error, Hashable, Sendable { // MARK: WebAuthnClient case noSupportedCredentialParameters case missingCredentialSourceDespiteSuccess + case timeoutError // MARK: Authenticator case unsupportedCredentialPublicKeyType @@ -139,6 +140,7 @@ public struct WebAuthnError: Error, Hashable, Sendable { // MARK: WebAuthnClient public static let noSupportedCredentialParameters = Self(reason: .noSupportedCredentialParameters) public static let missingCredentialSourceDespiteSuccess = Self(reason: .missingCredentialSourceDespiteSuccess) + public static let timeoutError = Self(reason: .timeoutError) // MARK: Authenticator public static let unsupportedCredentialPublicKeyType = Self(reason: .unsupportedCredentialPublicKeyType)