Skip to content

Commit 9c97bea

Browse files
Added start of WebAuthn client registration implementation
1 parent 7bc6bda commit 9c97bea

File tree

5 files changed

+253
-2
lines changed

5 files changed

+253
-2
lines changed

Sources/WebAuthn/Authenticators/KeyPairAuthenticator.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ public struct KeyPairAuthenticator: AuthenticatorProtocol, Sendable {
2222
public let canPerformUserVerification: Bool = true
2323
public let canStoreCredentialSourceClientSide: Bool = true
2424

25+
/// The specific subset the client fully supports, in case more are added over time.
26+
static let implementedPublicKeyCredentialParameterSubset: Set<PublicKeyCredentialParameters> = [
27+
PublicKeyCredentialParameters(alg: .algES256),
28+
]
29+
2530
/// Initialize a key-pair based authenticator with a globally unique ID representing your application.
2631
/// - 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.
2732
/// - Parameter attestationGloballyUniqueID: The AAGUID associated with the authenticator.
@@ -34,7 +39,7 @@ public struct KeyPairAuthenticator: AuthenticatorProtocol, Sendable {
3439
) {
3540
self.attestationGloballyUniqueID = attestationGloballyUniqueID
3641
self.attachmentModality = attachmentModality
37-
self.supportedPublicKeyCredentialParameters = supportedPublicKeyCredentialParameters
42+
self.supportedPublicKeyCredentialParameters = supportedPublicKeyCredentialParameters.intersection(Self.implementedPublicKeyCredentialParameterSubset)
3843
}
3944

4045
public func generateCredentialSource(

Sources/WebAuthn/Authenticators/Protocol/AuthenticatorProtocol.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ extension AuthenticatorProtocol {
124124
public func makeCredentials(
125125
with registration: AttestationRegistrationRequest
126126
) async throws -> CredentialSource {
127+
guard let chosenCredentialParameters = registration.publicKeyCredentialParameters.first(where: supportedPublicKeyCredentialParameters.contains(_:))
128+
else { throw WebAuthnError.noSupportedCredentialParameters }
129+
127130
throw WebAuthnError.unsupported
128131
}
129132
}

Sources/WebAuthn/WebAuthnClient.swift

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the WebAuthn Swift open source project
4+
//
5+
// Copyright (c) 2024 the WebAuthn Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of WebAuthn Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import Foundation
16+
import Crypto
17+
18+
/// A client implementation capable of interfacing between an ``AuthenticatorProtocol`` authenticator and the Web Authentication API.
19+
///
20+
/// - 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.
21+
///
22+
/// 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.
23+
public struct WebAuthnClient {
24+
public init() {}
25+
26+
public func createRegistrationCredential(
27+
options: PublicKeyCredentialCreationOptions,
28+
/// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout
29+
minTimeout: Duration = .seconds(300),
30+
maxTimeout: Duration = .seconds(600),
31+
origin: String,
32+
supportedPublicKeyCredentialParameters: Set<PublicKeyCredentialParameters> = .supported,
33+
attestRegistration: (_ registration: AttestationRegistrationRequest) async throws -> ()
34+
) async throws -> RegistrationCredential {
35+
/// Steps: https://w3c.github.io/webauthn/#sctn-createCredential
36+
37+
/// Step 1. Assert: options.publicKey is present.
38+
// Skip.
39+
40+
/// Step 2. If sameOriginWithAncestors is false:
41+
/// 1. If the relevant global object, as determined by the calling create() implementation, does not have transient activation:
42+
/// 1. Throw a "NotAllowedError" DOMException.
43+
/// 2. Consume user activation of the relevant global object.
44+
// Skip.
45+
46+
/// Step 3. Let pkOptions be the value of options.publicKey.
47+
// Skip.
48+
49+
/// 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.
50+
///
51+
/// See the recommended range and default for a WebAuthn ceremony timeout for guidance on deciding a reasonable range and default for pkOptions.timeout.
52+
let proposedTimeout = options.timeout ?? minTimeout
53+
let timeout = max(minTimeout, min(proposedTimeout, maxTimeout))
54+
55+
/// Step 5. If the length of pkOptions.user.id is not between 1 and 64 bytes (inclusive) then throw a TypeError.
56+
guard 1...64 ~= options.user.id.count
57+
else { throw WebAuthnError.invalidUserID }
58+
59+
/// Step 6. Let callerOrigin be origin. If callerOrigin is an opaque origin, throw a "NotAllowedError" DOMException.
60+
let callerOrigin = origin
61+
62+
/// Step 7. Let effectiveDomain be the callerOrigin’s effective domain. If effective domain is not a valid domain, then throw a "SecurityError" DOMException.
63+
// Skip.
64+
65+
/// Step 8. If pkOptions.rp.id
66+
/// → is present
67+
/// If pkOptions.rp.id is not a registrable domain suffix of and is not equal to effectiveDomain, throw a "SecurityError" DOMException.
68+
/// → Is not present
69+
/// Set pkOptions.rp.id to effectiveDomain.
70+
// Skip.
71+
72+
/// Step 11. Let credTypesAndPubKeyAlgs be a new list whose items are pairs of PublicKeyCredentialType and a COSEAlgorithmIdentifier.
73+
var publicKeyCredentialParameters: [PublicKeyCredentialParameters] = []
74+
75+
/// Step 12. If pkOptions.pubKeyCredParams’s size
76+
/// → is zero
77+
/// Append the following pairs of PublicKeyCredentialType and COSEAlgorithmIdentifier values to credTypesAndPubKeyAlgs:
78+
/// public-key and -7 ("ES256").
79+
/// public-key and -257 ("RS256").
80+
/// → is non-zero
81+
/// For each current of pkOptions.pubKeyCredParams:
82+
/// 1. If current.type does not contain a PublicKeyCredentialType supported by this implementation, then continue.
83+
/// 2. Let alg be current.alg.
84+
/// 3. Append the pair of current.type and alg to credTypesAndPubKeyAlgs.
85+
/// If credTypesAndPubKeyAlgs is empty, throw a "NotSupportedError" DOMException.
86+
if options.publicKeyCredentialParameters.isEmpty {
87+
publicKeyCredentialParameters = [
88+
PublicKeyCredentialParameters(alg: .algES256),
89+
// PublicKeyCredentialParameters(alg: .algRS256),
90+
]
91+
} else {
92+
for credentialParameter in options.publicKeyCredentialParameters {
93+
guard supportedPublicKeyCredentialParameters.contains(credentialParameter)
94+
else { continue }
95+
publicKeyCredentialParameters.append(credentialParameter)
96+
}
97+
guard !publicKeyCredentialParameters.isEmpty
98+
else { throw WebAuthnError.noSupportedCredentialParameters }
99+
}
100+
101+
/// Step 15. Let clientExtensions be a new map and let authenticatorExtensions be a new map.
102+
// Skip.
103+
104+
/// Step 16. If pkOptions.extensions is present, then for each extensionId → clientExtensionInput of pkOptions.extensions:
105+
/// 1. If extensionId is not supported by this client platform or is not a registration extension, then continue.
106+
/// 2. Set clientExtensions[extensionId] to clientExtensionInput.
107+
/// 3. If extensionId is not an authenticator extension, then continue.
108+
/// 4. Let authenticatorExtensionInput be the (CBOR) result of running extensionId’s client extension processing algorithm on clientExtensionInput. If the algorithm returned an error, continue.
109+
/// 5. Set authenticatorExtensions[extensionId] to the base64url encoding of authenticatorExtensionInput.
110+
// Skip.
111+
112+
/// Step 17. Let collectedClientData be a new CollectedClientData instance whose fields are:
113+
let collectedClientData = CollectedClientData(
114+
/// type
115+
/// The string "webauthn.create".
116+
type: .create,
117+
/// challenge
118+
/// The base64url encoding of pkOptions.challenge.
119+
challenge: options.challenge.base64URLEncodedString(),
120+
/// origin
121+
/// The serialization of callerOrigin.
122+
origin: callerOrigin
123+
/// topOrigin
124+
/// The serialization of callerOrigin’s top-level origin if the sameOriginWithAncestors argument passed to this internal method is false, else undefined.
125+
// Skip.
126+
/// crossOrigin
127+
/// The inverse of the value of the sameOriginWithAncestors argument passed to this internal method.
128+
// Skip.
129+
)
130+
131+
/// Step 18. Let clientDataJSON be the JSON-compatible serialization of client data constructed from collectedClientData.
132+
let clientDataJSON = try JSONEncoder().encode(collectedClientData)
133+
134+
/// Step 19. Let clientDataHash be the hash of the serialized client data represented by clientDataJSON.
135+
let clientDataHash = SHA256.hash(data: clientDataJSON)
136+
137+
/// Step 20. If options.signal is present and aborted, throw the options.signal’s abort reason.
138+
// Skip.
139+
140+
/// Step 21. Let issuedRequests be a new ordered set.
141+
// Skip.
142+
143+
/// 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.
144+
// Skip.
145+
146+
/// Step 23. Consider the value of hints and craft the user interface accordingly, as the user-agent sees fit.
147+
// Skip.
148+
149+
/// Step 24. Start lifetimeTimer.
150+
// Skip.
151+
152+
/// Step 25. While lifetimeTimer has not expired, perform the following actions depending upon lifetimeTimer, and the state and response for each authenticator in authenticators:
153+
// Skip.
154+
155+
throw WebAuthnError.unsupported
156+
}
157+
}
158+
159+
// MARK: Convenience Registration and Authentication
160+
161+
extension WebAuthnClient {
162+
@inlinable
163+
public func createRegistrationCredential<Authenticator: AuthenticatorProtocol & Sendable>(
164+
options: PublicKeyCredentialCreationOptions,
165+
/// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout
166+
minTimeout: Duration = .seconds(300),
167+
maxTimeout: Duration = .seconds(600),
168+
origin: String,
169+
supportedPublicKeyCredentialParameters: Set<PublicKeyCredentialParameters> = .supported,
170+
authenticator: Authenticator
171+
) async throws -> (registrationCredential: RegistrationCredential, credentialSource: Authenticator.CredentialSource) {
172+
var credentialSource: Authenticator.CredentialSource?
173+
let registrationCredential = try await createRegistrationCredential(
174+
options: options,
175+
minTimeout: minTimeout,
176+
maxTimeout: maxTimeout,
177+
origin: origin,
178+
supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters
179+
) { registration in
180+
credentialSource = try await authenticator.makeCredentials(with: registration)
181+
}
182+
183+
guard let credentialSource
184+
else { throw WebAuthnError.missingCredentialSourceDespiteSuccess }
185+
186+
return (registrationCredential, credentialSource)
187+
}
188+
189+
@inlinable
190+
public func createRegistrationCredential<each Authenticator: AuthenticatorProtocol & Sendable>(
191+
options: PublicKeyCredentialCreationOptions,
192+
/// Recommended Range: https://w3c.github.io/webauthn/#recommended-range-and-default-for-a-webauthn-ceremony-timeout
193+
minTimeout: Duration = .seconds(300),
194+
maxTimeout: Duration = .seconds(600),
195+
origin: String,
196+
supportedPublicKeyCredentialParameters: Set<PublicKeyCredentialParameters> = .supported,
197+
authenticators: repeat each Authenticator
198+
) async throws -> (
199+
registrationCredential: RegistrationCredential,
200+
credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>)
201+
) {
202+
/// Wrapper function since `repeat` doesn't currently support complex expressions
203+
@Sendable func register<LocalAuthenticator: AuthenticatorProtocol & Sendable>(
204+
authenticator: LocalAuthenticator,
205+
registration: AttestationRegistrationRequest
206+
) -> Task<LocalAuthenticator.CredentialSource, Error> {
207+
Task { try await authenticator.makeCredentials(with: registration) }
208+
}
209+
210+
var credentialSources: (repeat Result<(each Authenticator).CredentialSource, Error>)?
211+
let registrationCredential = try await createRegistrationCredential(
212+
options: options,
213+
minTimeout: minTimeout,
214+
maxTimeout: maxTimeout,
215+
origin: origin,
216+
supportedPublicKeyCredentialParameters: supportedPublicKeyCredentialParameters
217+
) { registration in
218+
/// Run each authenticator in parallel as child tasks, so we can automatically propagate cancellation to each of them should it occur.
219+
let tasks = (repeat register(
220+
authenticator: each authenticators,
221+
registration: registration
222+
))
223+
await withTaskCancellationHandler {
224+
credentialSources = (repeat await (each tasks).result)
225+
} onCancel: {
226+
repeat (each tasks).cancel()
227+
}
228+
}
229+
230+
guard let credentialSources
231+
else { throw WebAuthnError.missingCredentialSourceDespiteSuccess }
232+
233+
return (registrationCredential, credentialSources)
234+
}
235+
}

Sources/WebAuthn/WebAuthnError.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ public struct WebAuthnError: Error, Hashable, Sendable {
6666
case invalidExponent
6767
case unsupportedCOSEAlgorithmForRSAPublicKey
6868
case unsupported
69+
70+
// MARK: WebAuthnClient
71+
case noSupportedCredentialParameters
72+
case missingCredentialSourceDespiteSuccess
6973

7074
// MARK: Authenticator
7175
case unsupportedCredentialPublicKeyType
@@ -130,6 +134,10 @@ public struct WebAuthnError: Error, Hashable, Sendable {
130134
public static let unsupportedCOSEAlgorithmForRSAPublicKey = Self(reason: .unsupportedCOSEAlgorithmForRSAPublicKey)
131135
public static let unsupported = Self(reason: .unsupported)
132136

137+
// MARK: WebAuthnClient
138+
public static let noSupportedCredentialParameters = Self(reason: .noSupportedCredentialParameters)
139+
public static let missingCredentialSourceDespiteSuccess = Self(reason: .missingCredentialSourceDespiteSuccess)
140+
133141
// MARK: Authenticator
134142
public static let unsupportedCredentialPublicKeyType = Self(reason: .unsupportedCredentialPublicKeyType)
135143
public static let authorizationGestureNotAllowed = Self(reason: .authorizationGestureNotAllowed)

Sources/WebAuthn/WebAuthnManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import Foundation
1616

17-
/// Main entrypoint for WebAuthn operations.
17+
/// Main entrypoint for WebAuthn relying party (aka server-based) operations.
1818
///
1919
/// Use this struct to perform registration and authentication ceremonies.
2020
///

0 commit comments

Comments
 (0)