From a823806af73eeea9542e0f223bae8270b4f4e95e Mon Sep 17 00:00:00 2001 From: Marius Seufzer <44228394+marius-se@users.noreply.github.com> Date: Thu, 6 Jul 2023 09:22:16 +0200 Subject: [PATCH] Fix library biases (#28) * change user id and challenge type to byte array * rename a few properties * update createRegistrationOptions documentation * make PublicKeyCredentialUserEntity a struct * make storedChallenge byte array instead of base64 encoded * fix CollectedClientData challenge type * fix CollectedClientData challenge type * add getChallenge to RegistrationCredential * remove Codable conformances * wip * wip * wip * conform PublicKeyCredentialRequestOptions to Encodable * wip * fix config documentation comment * revert renaming beginRegistration * update documentation comments * fix tests --- .swiftlint.yml | 1 + .../AuthenticationCredential.swift | 30 +++- .../AuthenticatorAssertionResponse.swift | 68 ++++++--- .../PublicKeyCredentialRequestOptions.swift | 57 ++++++-- .../VerifiedAuthentication.swift | 2 +- .../AttestationConveyancePreference.swift | 2 +- .../AuthenticatorAttestationResponse.swift | 38 +++-- .../PublicKeyCredentialCreationOptions.swift | 136 +++++++++++++++--- .../Registration/RegistrationCredential.swift | 46 ++++-- .../Ceremonies/Registration/User.swift | 24 ---- .../Shared/COSE/COSEAlgorithmIdentifier.swift | 2 +- .../Ceremonies/Shared/COSE/COSEKeyType.swift | 2 +- .../Shared/CollectedClientData.swift | 16 ++- Sources/WebAuthn/Docs.docc/index.md | 2 +- .../WebAuthn/Helpers/Base64Utilities.swift | 38 +++-- .../WebAuthn/Helpers/ChallengeGenerator.swift | 2 - ...edDecodingContainer+decodeURLEncoded.swift | 33 +++++ Sources/WebAuthn/WebAuthnConfig.swift | 43 ------ Sources/WebAuthn/WebAuthnError.swift | 3 - Sources/WebAuthn/WebAuthnManager+Config.swift | 47 ++++++ Sources/WebAuthn/WebAuthnManager.swift | 56 ++++---- Tests/WebAuthnTests/Mocks/MockUser.swift | 12 +- .../TestModels/TestAttestationObject.swift | 6 +- .../Utils/TestModels/TestClientDataJSON.swift | 10 +- .../Utils/TestModels/TestConstants.swift | 5 +- .../Utils/TestModels/TestECCKeyPair.swift | 4 +- .../WebAuthnManagerAuthenticationTests.swift | 74 +++++----- .../WebAuthnManagerIntegrationTests.swift | 68 ++++----- .../WebAuthnManagerRegistrationTests.swift | 114 +++++++-------- 29 files changed, 580 insertions(+), 361 deletions(-) delete mode 100644 Sources/WebAuthn/Ceremonies/Registration/User.swift create mode 100644 Sources/WebAuthn/Helpers/KeyedDecodingContainer+decodeURLEncoded.swift delete mode 100644 Sources/WebAuthn/WebAuthnConfig.swift create mode 100644 Sources/WebAuthn/WebAuthnManager+Config.swift diff --git a/.swiftlint.yml b/.swiftlint.yml index 1caf0f0..b2ea2ec 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -7,6 +7,7 @@ excluded: identifier_name: excluded: - id + - rp line_length: ignores_comments: true \ No newline at end of file diff --git a/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift b/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift index 33a2987..7971855 100644 --- a/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift +++ b/Sources/WebAuthn/Ceremonies/Authentication/AuthenticationCredential.swift @@ -15,14 +15,40 @@ import Foundation /// The unprocessed response received from `navigator.credentials.get()`. -public struct AuthenticationCredential: Codable { +/// +/// When decoding using `Decodable`, the `rawID` is decoded from base64url to bytes. +public struct AuthenticationCredential { + /// The credential ID of the newly created credential. public let id: URLEncodedBase64 + + /// The raw credential ID of the newly created credential. + public let rawID: [UInt8] + + /// The attestation response from the authenticator. public let response: AuthenticatorAssertionResponse + + /// Reports the authenticator attachment modality in effect at the time the navigator.credentials.create() or + /// navigator.credentials.get() methods successfully complete public let authenticatorAttachment: String? + + /// Value will always be "public-key" (for now) public let type: String +} + +extension AuthenticationCredential: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(URLEncodedBase64.self, forKey: .id) + rawID = try container.decodeBytesFromURLEncodedBase64(forKey: .rawID) + response = try container.decode(AuthenticatorAssertionResponse.self, forKey: .response) + authenticatorAttachment = try container.decodeIfPresent(String.self, forKey: .authenticatorAttachment) + type = try container.decode(String.self, forKey: .type) + } - enum CodingKeys: String, CodingKey { + private enum CodingKeys: String, CodingKey { case id + case rawID = "rawId" case response case authenticatorAttachment case type diff --git a/Sources/WebAuthn/Ceremonies/Authentication/AuthenticatorAssertionResponse.swift b/Sources/WebAuthn/Ceremonies/Authentication/AuthenticatorAssertionResponse.swift index 2972600..bff588f 100644 --- a/Sources/WebAuthn/Ceremonies/Authentication/AuthenticatorAssertionResponse.swift +++ b/Sources/WebAuthn/Ceremonies/Authentication/AuthenticatorAssertionResponse.swift @@ -16,21 +16,57 @@ import Foundation import Crypto /// This is what the authenticator device returned after we requested it to authenticate a user. -public struct AuthenticatorAssertionResponse: Codable { +/// +/// When decoding using `Decodable`, byte arrays are decoded from base64url to bytes. +public struct AuthenticatorAssertionResponse { /// Representation of what we passed to `navigator.credentials.get()` - public let clientDataJSON: URLEncodedBase64 + /// + /// When decoding using `Decodable`, this is decoded from base64url to bytes. + public let clientDataJSON: [UInt8] + /// Contains the authenticator data returned by the authenticator. - public let authenticatorData: URLEncodedBase64 + /// + /// When decoding using `Decodable`, this is decoded from base64url to bytes. + public let authenticatorData: [UInt8] + /// Contains the raw signature returned from the authenticator - public let signature: URLEncodedBase64 + /// + /// When decoding using `Decodable`, this is decoded from base64url to bytes. + public let signature: [UInt8] + /// Contains the user handle returned from the authenticator, or null if the authenticator did not return /// a user handle. Used by to give scope to credentials. - public let userHandle: String? + /// + /// When decoding using `Decodable`, this is decoded from base64url to bytes. + public let userHandle: [UInt8]? + /// Contains an attestation object, if the authenticator supports attestation in assertions. /// The attestation object, if present, includes an attestation statement. Unlike the attestationObject /// in an AuthenticatorAttestationResponse, it does not contain an authData key because the authenticator /// data is provided directly in an AuthenticatorAssertionResponse structure. - public let attestationObject: String? + /// + /// When decoding using `Decodable`, this is decoded from base64url to bytes. + public let attestationObject: [UInt8]? +} + +extension AuthenticatorAssertionResponse: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + clientDataJSON = try container.decodeBytesFromURLEncodedBase64(forKey: .clientDataJSON) + authenticatorData = try container.decodeBytesFromURLEncodedBase64(forKey: .authenticatorData) + signature = try container.decodeBytesFromURLEncodedBase64(forKey: .signature) + userHandle = try container.decodeBytesFromURLEncodedBase64IfPresent(forKey: .userHandle) + attestationObject = try container.decodeBytesFromURLEncodedBase64IfPresent(forKey: .attestationObject) + } + + private enum CodingKeys: String, CodingKey { + case clientDataJSON + case authenticatorData + case signature + case userHandle + case attestationObject + } } struct ParsedAuthenticatorAssertionResponse { @@ -39,27 +75,21 @@ struct ParsedAuthenticatorAssertionResponse { let rawAuthenticatorData: Data let authenticatorData: AuthenticatorData let signature: URLEncodedBase64 - let userHandle: String? + let userHandle: [UInt8]? init(from authenticatorAssertionResponse: AuthenticatorAssertionResponse) throws { - guard let clientDataData = authenticatorAssertionResponse.clientDataJSON.urlDecoded.decoded else { - throw WebAuthnError.invalidClientDataJSON - } - rawClientData = clientDataData - clientData = try JSONDecoder().decode(CollectedClientData.self, from: clientDataData) + rawClientData = Data(authenticatorAssertionResponse.clientDataJSON) + clientData = try JSONDecoder().decode(CollectedClientData.self, from: rawClientData) - guard let authenticatorDataBytes = authenticatorAssertionResponse.authenticatorData.urlDecoded.decoded else { - throw WebAuthnError.invalidAuthenticatorData - } - rawAuthenticatorData = authenticatorDataBytes - authenticatorData = try AuthenticatorData(bytes: authenticatorDataBytes) - signature = authenticatorAssertionResponse.signature + rawAuthenticatorData = Data(authenticatorAssertionResponse.authenticatorData) + authenticatorData = try AuthenticatorData(bytes: rawAuthenticatorData) + signature = authenticatorAssertionResponse.signature.base64URLEncodedString() userHandle = authenticatorAssertionResponse.userHandle } // swiftlint:disable:next function_parameter_count func verify( - expectedChallenge: URLEncodedBase64, + expectedChallenge: [UInt8], relyingPartyOrigin: String, relyingPartyID: String, requireUserVerification: Bool, diff --git a/Sources/WebAuthn/Ceremonies/Authentication/PublicKeyCredentialRequestOptions.swift b/Sources/WebAuthn/Ceremonies/Authentication/PublicKeyCredentialRequestOptions.swift index 96fa878..7c4c170 100644 --- a/Sources/WebAuthn/Ceremonies/Authentication/PublicKeyCredentialRequestOptions.swift +++ b/Sources/WebAuthn/Ceremonies/Authentication/PublicKeyCredentialRequestOptions.swift @@ -15,27 +15,56 @@ import Foundation /// The `PublicKeyCredentialRequestOptions` gets passed to the WebAuthn API (`navigator.credentials.get()`) -public struct PublicKeyCredentialRequestOptions: Codable { +/// +/// When encoding using `Encodable`, the byte arrays are encoded as base64url. +public struct PublicKeyCredentialRequestOptions: Encodable { /// A challenge that the authenticator signs, along with other data, when producing an authentication assertion - public let challenge: EncodedBase64 + /// + /// When encoding using `Encodable` this is encoded as base64url. + public let challenge: [UInt8] + /// The number of milliseconds that the Relying Party is willing to wait for the call to complete. The value is treated /// as a hint, and may be overridden by the client. /// See https://www.w3.org/TR/webauthn-2/#dictionary-assertion-options public let timeout: UInt32? + /// The Relying Party ID. public let rpId: String? + /// Optionally used by the client to find authenticators eligible for this authentication ceremony. public let allowCredentials: [PublicKeyCredentialDescriptor]? + /// Specifies whether the user should be verified during the authentication ceremony. public let userVerification: UserVerificationRequirement? + // let extensions: [String: Any] + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(challenge.base64URLEncodedString(), forKey: .challenge) + try container.encodeIfPresent(timeout, forKey: .timeout) + try container.encodeIfPresent(rpId, forKey: .rpId) + try container.encodeIfPresent(allowCredentials, forKey: .allowCredentials) + try container.encodeIfPresent(userVerification, forKey: .userVerification) + } + + private enum CodingKeys: String, CodingKey { + case challenge + case timeout + case rpId + case allowCredentials + case userVerification + } } /// Information about a generated credential. -public struct PublicKeyCredentialDescriptor: Codable, Equatable { +/// +/// When encoding using `Encodable`, `id` is encoded as base64url. +public struct PublicKeyCredentialDescriptor: Equatable, Encodable { /// 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, Codable, Equatable { + public enum AuthenticatorTransport: String, Equatable, Encodable { /// Indicates the respective authenticator can be contacted over removable USB. case usb /// Indicates the respective authenticator can be contacted over Near Field Communication (NFC). @@ -51,14 +80,14 @@ public struct PublicKeyCredentialDescriptor: Codable, Equatable { case `internal` } - enum CodingKeys: String, CodingKey { - case type, id, transports - } - /// Will always be 'public-key' public let type: String + /// The sequence of bytes representing the credential's ID + /// + /// When encoding using `Encodable`, this is encoded as base64url. public let id: [UInt8] + /// The types of connections to the client/browser the authenticator supports public let transports: [AuthenticatorTransport] @@ -72,14 +101,20 @@ public struct PublicKeyCredentialDescriptor: Codable, Equatable { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(type, forKey: .type) - try container.encode(id.base64EncodedString(), forKey: .id) - try container.encode(transports, forKey: .transports) + try container.encode(id.base64URLEncodedString(), forKey: .id) + try container.encodeIfPresent(transports, forKey: .transports) + } + + private enum CodingKeys: String, CodingKey { + case type + case id + case transports } } /// 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, Codable { +public enum UserVerificationRequirement: String, Encodable { /// The Relying Party requires user verification for the operation and will fail the overall ceremony if the /// user wasn't verified. case required diff --git a/Sources/WebAuthn/Ceremonies/Authentication/VerifiedAuthentication.swift b/Sources/WebAuthn/Ceremonies/Authentication/VerifiedAuthentication.swift index fcec6df..0bf54b6 100644 --- a/Sources/WebAuthn/Ceremonies/Authentication/VerifiedAuthentication.swift +++ b/Sources/WebAuthn/Ceremonies/Authentication/VerifiedAuthentication.swift @@ -16,7 +16,7 @@ import Foundation /// On successful authentication, this structure contains a summary of the authentication flow public struct VerifiedAuthentication { - public enum CredentialDeviceType: String, Codable { + public enum CredentialDeviceType: String { case singleDevice = "single_device" case multiDevice = "multi_device" } diff --git a/Sources/WebAuthn/Ceremonies/Registration/AttestationConveyancePreference.swift b/Sources/WebAuthn/Ceremonies/Registration/AttestationConveyancePreference.swift index 2266941..cba6e10 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, Codable { +public enum AttestationConveyancePreference: String, Encodable { /// Indicates the Relying Party is not interested in authenticator attestation. case none // case indirect diff --git a/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift b/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift index 58801bc..8a60466 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/AuthenticatorAttestationResponse.swift @@ -16,9 +16,32 @@ import Foundation import SwiftCBOR /// The response from the authenticator device for the creation of a new public key credential. -public struct AuthenticatorAttestationResponse: Codable { - public let clientDataJSON: URLEncodedBase64 - public let attestationObject: URLEncodedBase64 +/// +/// When decoding using `Decodable`, `clientDataJSON` and `attestationObject` are decoded from base64url to bytes. +public struct AuthenticatorAttestationResponse { + /// The client data that was passed to the authenticator during the creation ceremony. + /// + /// When decoding using `Decodable`, this is decoded from base64url to bytes. + public let clientDataJSON: [UInt8] + + /// Contains both attestation data and attestation statement. + /// + /// When decoding using `Decodable`, this is decoded from base64url to bytes. + public let attestationObject: [UInt8] +} + +extension AuthenticatorAttestationResponse: Decodable { + 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) + } + + private enum CodingKeys: String, CodingKey { + case clientDataJSON + case attestationObject + } } /// A parsed version of `AuthenticatorAttestationResponse` @@ -28,15 +51,12 @@ struct ParsedAuthenticatorAttestationResponse { init(from rawResponse: AuthenticatorAttestationResponse) throws { // assembling clientData - guard let clientDataJSONData = rawResponse.clientDataJSON.urlDecoded.decoded else { - throw WebAuthnError.invalidClientDataJSON - } - let clientData = try JSONDecoder().decode(CollectedClientData.self, from: clientDataJSONData) + let clientData = try JSONDecoder().decode(CollectedClientData.self, from: Data(rawResponse.clientDataJSON)) self.clientData = clientData // Step 11. (assembling attestationObject) - guard let attestationObjectData = rawResponse.attestationObject.urlDecoded.decoded, - let decodedAttestationObject = try CBOR.decode([UInt8](attestationObjectData)) else { + let attestationObjectData = Data(rawResponse.attestationObject) + guard let decodedAttestationObject = try? CBOR.decode([UInt8](attestationObjectData)) else { throw WebAuthnError.invalidAttestationObject } diff --git a/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift b/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift index 08f59bd..d32aa35 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/PublicKeyCredentialCreationOptions.swift @@ -12,52 +12,142 @@ // //===----------------------------------------------------------------------===// -import Foundation - /// The `PublicKeyCredentialCreationOptions` gets passed to the WebAuthn API (`navigator.credentials.create()`) -public struct PublicKeyCredentialCreationOptions: Codable { - public let challenge: EncodedBase64 +/// +/// Generally this should not be created manually. Instead use `RelyingParty.beginRegistration()`. When encoding using +/// `Encodable` byte arrays are base64url encoded. +public struct PublicKeyCredentialCreationOptions: Encodable { + /// A byte array randomly generated by the Relying Party. Should be at least 16 bytes long to ensure sufficient + /// entropy. + /// + /// The Relying Party should store the challenge temporarily until the registration flow is complete. When + /// encoding using `Encodable`, the challenge is base64url encoded. + public let challenge: [UInt8] + + /// Contains names and an identifier for the user account performing the registration public let user: PublicKeyCredentialUserEntity - // swiftlint:disable:next identifier_name - public let rp: PublicKeyCredentialRpEntity - public let pubKeyCredParams: [PublicKeyCredentialParameters] - public let timeout: TimeInterval + + /// Contains a name and an identifier for the Relying Party responsible for the request + public let relyingParty: PublicKeyCredentialRpEntity + + /// A list of key types and signature algorithms the Relying Party supports. Ordered from most preferred to least + /// preferred. + public let publicKeyCredentialParameters: [PublicKeyCredentialParameters] + + /// A time, in milliseconds, that the caller is willing to wait for the call to complete. This is treated as a + /// hint, and may be overridden by the client. + public let timeoutInMilliseconds: UInt32? + + /// Sets the Relying Party's preference for attestation conveyance. At the time of writing only `none` is + /// supported. public let attestation: AttestationConveyancePreference + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(challenge.base64URLEncodedString(), forKey: .challenge) + try container.encode(user, forKey: .user) + try container.encode(relyingParty, forKey: .relyingParty) + try container.encode(publicKeyCredentialParameters, forKey: .publicKeyCredentialParameters) + try container.encodeIfPresent(timeoutInMilliseconds, forKey: .timeoutInMilliseconds) + try container.encode(attestation, forKey: .attestation) + } + + private enum CodingKeys: String, CodingKey { + case challenge + case user + case relyingParty = "rp" + case publicKeyCredentialParameters = "pubKeyCredParams" + case timeoutInMilliseconds = "timeout" + case attestation + } } // MARK: - Credential parameters - -public struct PublicKeyCredentialParameters: Equatable, Codable { +/// From §5.3 (https://w3c.github.io/TR/webauthn/#dictionary-credential-params) +public struct PublicKeyCredentialParameters: Equatable, Encodable { + /// The type of credential to be created. At the time of writing always "public-key". public let type: String + /// The cryptographic signature algorithm with which the newly generated credential will be used, and thus also + /// the type of asymmetric key pair to be generated, e.g., RSA or Elliptic Curve. public let alg: COSEAlgorithmIdentifier - public static var supported: [Self] { - COSEAlgorithmIdentifier.allCases.map { - PublicKeyCredentialParameters.init(type: "public-key", alg: $0) - } - } - + /// Creates a new `PublicKeyCredentialParameters` instance. + /// + /// - Parameters: + /// - type: The type of credential to be created. At the time of writing always "public-key". + /// - alg: The cryptographic signature algorithm to be used with the newly generated credential. + /// For example RSA or Elliptic Curve. public init(type: String = "public-key", alg: COSEAlgorithmIdentifier) { self.type = type self.alg = alg } } +extension Array where Element == PublicKeyCredentialParameters { + /// A list of `PublicKeyCredentialParameters` WebAuthn Swift currently supports. + public static var supported: [Element] { + COSEAlgorithmIdentifier.allCases.map { + Element.init(type: "public-key", alg: $0) + } + } +} + // MARK: - Credential entities /// From §5.4.2 (https://www.w3.org/TR/webauthn/#sctn-rp-credential-params). /// The PublicKeyCredentialRpEntity dictionary is used to supply additional Relying Party attributes when /// creating a new credential. -public struct PublicKeyCredentialRpEntity: Codable { - public let name: String +public struct PublicKeyCredentialRpEntity: Encodable { + /// A unique identifier for the Relying Party entity. public let id: String + + /// A human-readable identifier for the Relying Party, intended only for display. For example, "ACME Corporation", + /// "Wonderful Widgets, Inc." or "ОАО Примертех". + public let name: String + } -/// From §5.4.3 (https://www.w3.org/TR/webauthn/#dictionary-user-credential-params) -/// The PublicKeyCredentialUserEntity dictionary is used to supply additional user account attributes when -/// creating a new credential. -public struct PublicKeyCredentialUserEntity: Codable { + /// From §5.4.3 (https://www.w3.org/TR/webauthn/#dictionary-user-credential-params) + /// The PublicKeyCredentialUserEntity dictionary is used to supply additional user account attributes when + /// creating a new credential. + /// + /// When encoding using `Encodable`, `id` is base64url encoded. +public struct PublicKeyCredentialUserEntity: Encodable { + /// Generated by the Relying Party, unique to the user account, and must not contain personally identifying + /// information about the user. + /// + /// When encoding this is base64url encoded. + public let id: [UInt8] + + /// A human-readable identifier for the user account, intended only for display. It helps the user to + /// distinguish between user accounts with similar `displayName`s. For example, two different user accounts + /// might both have the same `displayName`, "Alex P. Müller", but might have different `name` values "alexm", + /// "alex.mueller@example.com" or "+14255551234". public let name: String - public let id: String + + /// A human-readable name for the user account, intended only for display. For example, "Alex P. Müller" or + /// "田中 倫" public let displayName: String + + /// Creates a new ``PublicKeyCredentialUserEntity`` from id, name and displayName + public init(id: [UInt8], name: String, displayName: String) { + self.id = id + self.name = name + self.displayName = displayName + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(id.base64URLEncodedString(), forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(displayName, forKey: .displayName) + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case displayName + } } diff --git a/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift b/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift index c4ef23f..8c3d578 100644 --- a/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift +++ b/Sources/WebAuthn/Ceremonies/Registration/RegistrationCredential.swift @@ -16,17 +16,40 @@ import Foundation import Crypto /// The unprocessed response received from `navigator.credentials.create()`. -public struct RegistrationCredential: Codable { +/// +/// When decoding using `Decodable`, the `rawID` is decoded from base64url to bytes. +public struct RegistrationCredential { /// The credential ID of the newly created credential. - public let id: String + public let id: URLEncodedBase64 + /// Value will always be "public-key" (for now) public let type: String + /// The raw credential ID of the newly created credential. - public let rawID: URLEncodedBase64 + public let rawID: [UInt8] + /// The attestation response from the authenticator. public let attestationResponse: AuthenticatorAttestationResponse +} + +extension RegistrationCredential: Decodable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) - enum CodingKeys: String, CodingKey { + id = try container.decode(URLEncodedBase64.self, forKey: .id) + type = try container.decode(String.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 + attestationResponse = try container.decode(AuthenticatorAttestationResponse.self, forKey: .attestationResponse) + } + + private enum CodingKeys: String, CodingKey { case id case type case rawID = "rawId" @@ -36,7 +59,7 @@ public struct RegistrationCredential: Codable { /// The processed response received from `navigator.credentials.create()`. struct ParsedCredentialCreationResponse { - let id: String + let id: URLEncodedBase64 let rawID: Data /// Value will always be "public-key" (for now) let type: String @@ -46,11 +69,7 @@ struct ParsedCredentialCreationResponse { /// Create a `ParsedCredentialCreationResponse` from a raw `CredentialCreationResponse`. init(from rawResponse: RegistrationCredential) throws { id = rawResponse.id - - guard let decodedRawID = rawResponse.rawID.urlDecoded.decoded else { - throw WebAuthnError.invalidRawID - } - rawID = decodedRawID + rawID = Data(rawResponse.rawID) guard rawResponse.type == "public-key" else { throw WebAuthnError.invalidCredentialCreationType @@ -63,7 +82,7 @@ struct ParsedCredentialCreationResponse { // swiftlint:disable:next function_parameter_count func verify( - storedChallenge: URLEncodedBase64, + storedChallenge: [UInt8], verifyUser: Bool, relyingPartyID: String, relyingPartyOrigin: String, @@ -78,10 +97,7 @@ struct ParsedCredentialCreationResponse { ) // Step 10. - guard let clientData = raw.clientDataJSON.urlDecoded.decoded else { - throw WebAuthnError.invalidClientDataJSON - } - let hash = SHA256.hash(data: clientData) + let hash = SHA256.hash(data: Data(raw.clientDataJSON)) // CBOR decoding happened already. Skipping Step 11. diff --git a/Sources/WebAuthn/Ceremonies/Registration/User.swift b/Sources/WebAuthn/Ceremonies/Registration/User.swift deleted file mode 100644 index fac0a34..0000000 --- a/Sources/WebAuthn/Ceremonies/Registration/User.swift +++ /dev/null @@ -1,24 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 -// -//===----------------------------------------------------------------------===// - -/// Protocol to interact with a user throughout the registration ceremony -public protocol WebAuthnUser { - /// A unique identifier for the user. For privacy reasons it should NOT be something like an email address. - var userID: String { get } - /// A value that will help the user identify which account this credential is associated with. - /// Can be an email address, etc... - var name: String { get } - /// A user-friendly representation of their account. Can be a full name ,etc... - var displayName: String { get } -} diff --git a/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEAlgorithmIdentifier.swift b/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEAlgorithmIdentifier.swift index a1d321f..40ac771 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, Codable, CaseIterable { +public enum COSEAlgorithmIdentifier: Int, RawRepresentable, CaseIterable, Encodable { /// AlgES256 ECDSA with SHA-256 case algES256 = -7 /// AlgES384 ECDSA with SHA-384 diff --git a/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEKeyType.swift b/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEKeyType.swift index e67b828..91b4f80 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEKeyType.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/COSE/COSEKeyType.swift @@ -15,7 +15,7 @@ import Foundation /// The Key Type derived from the IANA COSE AuthData -enum COSEKeyType: UInt64, RawRepresentable, Codable { +enum COSEKeyType: UInt64, RawRepresentable { /// OctetKey is an Octet Key case octetKey = 1 /// EllipticKey is an Elliptic Curve Public Key diff --git a/Sources/WebAuthn/Ceremonies/Shared/CollectedClientData.swift b/Sources/WebAuthn/Ceremonies/Shared/CollectedClientData.swift index 729a710..b09f851 100644 --- a/Sources/WebAuthn/Ceremonies/Shared/CollectedClientData.swift +++ b/Sources/WebAuthn/Ceremonies/Shared/CollectedClientData.swift @@ -23,21 +23,23 @@ public struct CollectedClientData: Codable, Hashable { case originDoesNotMatch } - enum CeremonyType: String, Codable { + public enum CeremonyType: String, Codable { case create = "webauthn.create" case assert = "webauthn.get" } /// Contains the string "webauthn.create" when creating new credentials, /// and "webauthn.get" when getting an assertion from an existing credential - let type: CeremonyType - /// Contains the base64url encoding of the challenge provided by the Relying Party - let challenge: URLEncodedBase64 - let origin: String + public let type: CeremonyType + /// The challenge that was provided by the Relying Party + public let challenge: URLEncodedBase64 + public let origin: String - func verify(storedChallenge: URLEncodedBase64, ceremonyType: CeremonyType, relyingPartyOrigin: String) throws { + func verify(storedChallenge: [UInt8], ceremonyType: CeremonyType, relyingPartyOrigin: String) throws { guard type == ceremonyType else { throw CollectedClientDataVerifyError.ceremonyTypeDoesNotMatch } - guard challenge == storedChallenge else { throw CollectedClientDataVerifyError.challengeDoesNotMatch } + guard challenge == storedChallenge.base64URLEncodedString() else { + throw CollectedClientDataVerifyError.challengeDoesNotMatch + } guard origin == relyingPartyOrigin else { throw CollectedClientDataVerifyError.originDoesNotMatch } } } diff --git a/Sources/WebAuthn/Docs.docc/index.md b/Sources/WebAuthn/Docs.docc/index.md index a1f07af..ffb65f1 100644 --- a/Sources/WebAuthn/Docs.docc/index.md +++ b/Sources/WebAuthn/Docs.docc/index.md @@ -24,7 +24,7 @@ the corresponding user in a database. - ``WebAuthnManager`` - ``WebAuthnConfig`` -- ``WebAuthnUser`` +- ``PublicKeyCredentialUserEntity`` ### Responses diff --git a/Sources/WebAuthn/Helpers/Base64Utilities.swift b/Sources/WebAuthn/Helpers/Base64Utilities.swift index 1901219..a37ef35 100644 --- a/Sources/WebAuthn/Helpers/Base64Utilities.swift +++ b/Sources/WebAuthn/Helpers/Base64Utilities.swift @@ -37,7 +37,7 @@ public struct EncodedBase64: ExpressibleByStringLiteral, Codable, Hashable, Equa try container.encode(self.base64) } - /// Return as URL encoded base64 + /// Return as Base64URL public var urlEncoded: URLEncodedBase64 { return .init( self.base64.replacingOccurrences(of: "+", with: "-") @@ -46,28 +46,29 @@ public struct EncodedBase64: ExpressibleByStringLiteral, Codable, Hashable, Equa ) } - /// Return base64 decoded data + /// Decodes Base64 string and transforms result into `Data` public var decoded: Data? { return Data(base64Encoded: self.base64) } - /// return Base64 data as a String + /// Returns Base64 data as a String public func asString() -> String { return self.base64 } - - /// return Base64 data as Data - public func asData() -> Data? { - return self.base64.data(using: .utf8) - } } /// Container for URL encoded base64 data public struct URLEncodedBase64: ExpressibleByStringLiteral, Codable, Hashable, Equatable { - let base64: String + let base64URL: String + + /// Decodes Base64URL string and transforms result into `[UInt8]` + public var decodedBytes: [UInt8]? { + guard let base64DecodedData = urlDecoded.decoded else { return nil } + return [UInt8](base64DecodedData) + } public init(_ string: String) { - self.base64 = string + self.base64URL = string } public init(stringLiteral value: StringLiteralType) { @@ -76,31 +77,26 @@ public struct URLEncodedBase64: ExpressibleByStringLiteral, Codable, Hashable, E public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() - self.base64 = try container.decode(String.self) + self.base64URL = try container.decode(String.self) } public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() - try container.encode(self.base64) + try container.encode(self.base64URL) } - /// Return URL decoded Base64 data + /// Decodes Base64URL into Base64 public var urlDecoded: EncodedBase64 { - var result = self.base64.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") + var result = self.base64URL.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/") while result.count % 4 != 0 { result = result.appending("=") } return .init(result) } - /// return Base64 data as a String + /// Return Base64URL as a String public func asString() -> String { - return self.base64 - } - - /// return Base64 data as Data - public func asData() -> Data? { - return self.base64.data(using: .utf8) + return self.base64URL } } diff --git a/Sources/WebAuthn/Helpers/ChallengeGenerator.swift b/Sources/WebAuthn/Helpers/ChallengeGenerator.swift index 401a9ab..440c076 100644 --- a/Sources/WebAuthn/Helpers/ChallengeGenerator.swift +++ b/Sources/WebAuthn/Helpers/ChallengeGenerator.swift @@ -12,8 +12,6 @@ // //===----------------------------------------------------------------------===// -import Foundation - public struct ChallengeGenerator { var generate: () -> [UInt8] diff --git a/Sources/WebAuthn/Helpers/KeyedDecodingContainer+decodeURLEncoded.swift b/Sources/WebAuthn/Helpers/KeyedDecodingContainer+decodeURLEncoded.swift new file mode 100644 index 0000000..85d42b2 --- /dev/null +++ b/Sources/WebAuthn/Helpers/KeyedDecodingContainer+decodeURLEncoded.swift @@ -0,0 +1,33 @@ +import Foundation + +extension KeyedDecodingContainer { + func decodeBytesFromURLEncodedBase64(forKey key: KeyedDecodingContainer.Key) throws -> [UInt8] { + guard let bytes = try decode( + URLEncodedBase64.self, + forKey: key + ).decodedBytes else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Failed to decode base64url encoded string at \(key) into bytes" + ) + } + return bytes + } + + func decodeBytesFromURLEncodedBase64IfPresent(forKey key: KeyedDecodingContainer.Key) throws -> [UInt8]? { + guard let bytes = try decodeIfPresent( + URLEncodedBase64.self, + forKey: key + ) else { return nil } + + guard let decodedBytes = bytes.decodedBytes else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: self, + debugDescription: "Failed to decode base64url encoded string at \(key) into bytes" + ) + } + return decodedBytes + } +} diff --git a/Sources/WebAuthn/WebAuthnConfig.swift b/Sources/WebAuthn/WebAuthnConfig.swift deleted file mode 100644 index 58b9f38..0000000 --- a/Sources/WebAuthn/WebAuthnConfig.swift +++ /dev/null @@ -1,43 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// 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 - -/// Config represents the WebAuthn configuration. -public struct WebAuthnConfig { - /// Configures the display name for the Relying Party Server. This can be any string. - public let relyingPartyDisplayName: String - /// The relying party id is based on the host's domain. - /// It does not include a scheme or port (like the `relyingPartyOrigin`). - /// For example, if the origin is https://login.example.com:1337, then _login.example.com_ or _example.com_ are - /// valid ids, but not _m.login.example.com_ and not _com_. - public let relyingPartyID: String - /// The domain, with HTTP protocol (e.g. "https://example.com") - public let relyingPartyOrigin: String - - /// Creates a new `WebAuthnConfig` with information about the Relying Party - /// - Parameters: - /// - relyingPartyDisplayName: Display name for the Relying Party. Can be any string. - /// - relyingPartyID: The relying party id is based on the host's domain. (e.g. _login.example.com_) - /// - relyingPartyOrigin: The domain, with HTTP protocol (e.g. _https://login.example.com_) - public init( - relyingPartyDisplayName: String, - relyingPartyID: String, - relyingPartyOrigin: String - ) { - self.relyingPartyDisplayName = relyingPartyDisplayName - self.relyingPartyID = relyingPartyID - self.relyingPartyOrigin = relyingPartyOrigin - } -} diff --git a/Sources/WebAuthn/WebAuthnError.swift b/Sources/WebAuthn/WebAuthnError.swift index ab6af27..d9411bd 100644 --- a/Sources/WebAuthn/WebAuthnError.swift +++ b/Sources/WebAuthn/WebAuthnError.swift @@ -14,7 +14,6 @@ public enum WebAuthnError: Error, Equatable { // MARK: Shared - case invalidClientDataJSON case attestedCredentialDataMissing case relyingPartyIDHashDoesNotMatch case userPresentFlagNotSet @@ -29,7 +28,6 @@ public enum WebAuthnError: Error, Equatable { case invalidUserID case unsupportedCredentialPublicKeyAlgorithm case credentialIDAlreadyExists - case invalidAuthenticatorData case invalidRelyingPartyID case userVerifiedFlagNotSet case potentialReplayAttack @@ -43,7 +41,6 @@ public enum WebAuthnError: Error, Equatable { case attestationFormatNotSupported // MARK: ParsedCredentialCreationResponse - case invalidRawID case invalidCredentialCreationType case credentialRawIDTooLong diff --git a/Sources/WebAuthn/WebAuthnManager+Config.swift b/Sources/WebAuthn/WebAuthnManager+Config.swift new file mode 100644 index 0000000..392324b --- /dev/null +++ b/Sources/WebAuthn/WebAuthnManager+Config.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// +// 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 + +extension WebAuthnManager { + /// Config represents the WebAuthn configuration. + public struct Config { + /// The relying party id is based on the host's domain. + /// It does not include a scheme or port (like the `relyingPartyOrigin`). + /// For example, if the origin is https://login.example.com:1337, then _login.example.com_ or _example.com_ are + /// valid ids, but not _m.login.example.com_ and not _com_. + public let relyingPartyID: String + + /// Configures the display name for the Relying Party Server. This can be any string. + public let relyingPartyName: String + + /// The domain, with HTTP protocol (e.g. "https://example.com") + public let relyingPartyOrigin: String + + /// Creates a new `WebAuthnConfig` with information about the Relying Party + /// - Parameters: + /// - relyingPartyID: The relying party id is based on the host's domain. (e.g. _login.example.com_) + /// - relyingPartyName: Name for the Relying Party. Can be any string. + /// - relyingPartyOrigin: The domain, with HTTP protocol (e.g. _https://login.example.com_) + public init( + relyingPartyID: String, + relyingPartyName: String, + relyingPartyOrigin: String + ) { + self.relyingPartyID = relyingPartyID + self.relyingPartyName = relyingPartyName + self.relyingPartyOrigin = relyingPartyOrigin + } + } +} diff --git a/Sources/WebAuthn/WebAuthnManager.swift b/Sources/WebAuthn/WebAuthnManager.swift index 4143eab..24fa14b 100644 --- a/Sources/WebAuthn/WebAuthnManager.swift +++ b/Sources/WebAuthn/WebAuthnManager.swift @@ -28,7 +28,7 @@ import Foundation /// When the client has received the response from the authenticator, pass the response to /// `finishAuthentication()`. public struct WebAuthnManager { - private let config: WebAuthnConfig + private let config: Config private let challengeGenerator: ChallengeGenerator @@ -37,40 +37,41 @@ public struct WebAuthnManager { /// - Parameters: /// - config: The configuration to use for this manager. /// - challengeGenerator: The challenge generator to use for this manager. Defaults to a live generator. - public init(config: WebAuthnConfig, challengeGenerator: ChallengeGenerator = .live) { + public init(config: Config, challengeGenerator: ChallengeGenerator = .live) { self.config = config self.challengeGenerator = challengeGenerator } - /// Generate a new set of registration data to be sent to the client and authenticator. + /// Generate a new set of registration data to be sent to the client. /// + /// This method will use the Relying Party information from the WebAuthnManager's config to create ``PublicKeyCredentialCreationOptions`` /// - Parameters: /// - user: The user to register. - /// - attestation: The level of attestation to be provided by the authenticator. + /// - timeoutInSeconds: How long the browser should give the user to choose an authenticator. This value + /// is a *hint* and may be ignored by the browser. Defaults to 60 seconds. + /// - attestation: The Relying Party's preference regarding attestation. Defaults to `.none`. /// - publicKeyCredentialParameters: A list of public key algorithms the Relying Party chooses to restrict /// support to. Defaults to all supported algorithms. /// - Returns: Registration options ready for the browser. public func beginRegistration( - user: WebAuthnUser, - timeout: TimeInterval = 60000, + user: PublicKeyCredentialUserEntity, + timeoutInSeconds: TimeInterval? = 3600, attestation: AttestationConveyancePreference = .none, - publicKeyCredentialParameters: [PublicKeyCredentialParameters] = PublicKeyCredentialParameters.supported - ) throws -> PublicKeyCredentialCreationOptions { - guard let base64ID = user.userID.data(using: .utf8)?.base64EncodedString() else { - throw WebAuthnError.invalidUserID - } - - let userEntity = PublicKeyCredentialUserEntity(name: user.name, id: base64ID, displayName: user.displayName) - let relyingParty = PublicKeyCredentialRpEntity(name: config.relyingPartyDisplayName, id: config.relyingPartyID) - + publicKeyCredentialParameters: [PublicKeyCredentialParameters] = .supported + ) -> PublicKeyCredentialCreationOptions { let challenge = challengeGenerator.generate() + var timeoutInMilliseconds: UInt32? + if let timeoutInSeconds { + timeoutInMilliseconds = UInt32(timeoutInSeconds * 1000) + } + return PublicKeyCredentialCreationOptions( - challenge: challenge.base64EncodedString(), - user: userEntity, - rp: relyingParty, - pubKeyCredParams: publicKeyCredentialParameters, - timeout: timeout, + challenge: challenge, + user: user, + relyingParty: .init(id: config.relyingPartyID, name: config.relyingPartyName), + publicKeyCredentialParameters: publicKeyCredentialParameters, + timeoutInMilliseconds: timeoutInMilliseconds, attestation: attestation ) } @@ -91,16 +92,16 @@ public struct WebAuthnManager { /// handle that. /// - Returns: A new `Credential` with information about the authenticator and registration public func finishRegistration( - challenge: EncodedBase64, + challenge: [UInt8], credentialCreationData: RegistrationCredential, requireUserVerification: Bool = false, - supportedPublicKeyAlgorithms: [PublicKeyCredentialParameters] = PublicKeyCredentialParameters.supported, + supportedPublicKeyAlgorithms: [PublicKeyCredentialParameters] = .supported, pemRootCertificatesByFormat: [AttestationFormat: [Data]] = [:], confirmCredentialIDNotRegisteredYet: (String) async throws -> Bool ) async throws -> Credential { let parsedData = try ParsedCredentialCreationResponse(from: credentialCreationData) let attestedCredentialData = try await parsedData.verify( - storedChallenge: challenge.urlEncoded, + storedChallenge: challenge, verifyUser: requireUserVerification, relyingPartyID: config.relyingPartyID, relyingPartyOrigin: config.relyingPartyOrigin, @@ -111,14 +112,14 @@ public struct WebAuthnManager { // TODO: Step 18. -> Verify client extensions // Step 24. - guard try await confirmCredentialIDNotRegisteredYet(parsedData.id) else { + guard try await confirmCredentialIDNotRegisteredYet(parsedData.id.asString()) else { throw WebAuthnError.credentialIDAlreadyExists } // Step 25. return Credential( type: parsedData.type, - id: parsedData.id, + id: parsedData.id.urlDecoded.asString(), publicKey: attestedCredentialData.publicKey, signCount: parsedData.response.attestationObject.authenticatorData.counter, backupEligible: parsedData.response.attestationObject.authenticatorData.flags.isBackupEligible, @@ -140,12 +141,11 @@ public struct WebAuthnManager { /// "user verified" flag. /// - Returns: Authentication options ready for the browser. public func beginAuthentication( - challenge: EncodedBase64? = nil, timeout: TimeInterval? = 60, allowCredentials: [PublicKeyCredentialDescriptor]? = nil, userVerification: UserVerificationRequirement = .preferred ) throws -> PublicKeyCredentialRequestOptions { - let challenge = challenge ?? challengeGenerator.generate().base64EncodedString() + let challenge = challengeGenerator.generate() var timeoutInMilliseconds: UInt32? = nil if let timeout { timeoutInMilliseconds = UInt32(timeout * 1000) @@ -172,7 +172,7 @@ public struct WebAuthnManager { public func finishAuthentication( credential: AuthenticationCredential, // clientExtensionResults: , - expectedChallenge: URLEncodedBase64, + expectedChallenge: [UInt8], credentialPublicKey: [UInt8], credentialCurrentSignCount: UInt32, requireUserVerification: Bool = false diff --git a/Tests/WebAuthnTests/Mocks/MockUser.swift b/Tests/WebAuthnTests/Mocks/MockUser.swift index bb76f13..1442b29 100644 --- a/Tests/WebAuthnTests/Mocks/MockUser.swift +++ b/Tests/WebAuthnTests/Mocks/MockUser.swift @@ -14,14 +14,6 @@ import WebAuthn -struct MockUser: WebAuthnUser { - var userID: String - var name: String - var displayName: String - - init(userID: String = "1", name: String = "John", displayName: String = "Johnny") { - self.userID = userID - self.name = name - self.displayName = displayName - } +extension PublicKeyCredentialUserEntity { + static let mock = PublicKeyCredentialUserEntity(id: [1, 2, 3], name: "John", displayName: "Johnny") } diff --git a/Tests/WebAuthnTests/Utils/TestModels/TestAttestationObject.swift b/Tests/WebAuthnTests/Utils/TestModels/TestAttestationObject.swift index 7a3f28f..6abdaca 100644 --- a/Tests/WebAuthnTests/Utils/TestModels/TestAttestationObject.swift +++ b/Tests/WebAuthnTests/Utils/TestModels/TestAttestationObject.swift @@ -22,7 +22,7 @@ struct TestAttestationObject { var attStmt: CBOR? var authData: CBOR? - var base64URLEncoded: URLEncodedBase64 { + var cborEncoded: [UInt8] { var attestationObject: [CBOR: CBOR] = [:] if let fmt { attestationObject[.utf8String("fmt")] = fmt @@ -34,7 +34,7 @@ struct TestAttestationObject { attestationObject[.utf8String("authData")] = authData } - return CBOR.map(attestationObject).encode().base64URLEncodedString() + return [UInt8](CBOR.map(attestationObject).encode()) } } @@ -58,7 +58,7 @@ struct TestAttestationObjectBuilder { } func buildBase64URLEncoded() -> URLEncodedBase64 { - build().base64URLEncoded + build().cborEncoded.base64URLEncodedString() } // MARK: fmt diff --git a/Tests/WebAuthnTests/Utils/TestModels/TestClientDataJSON.swift b/Tests/WebAuthnTests/Utils/TestModels/TestClientDataJSON.swift index e645f1e..8310277 100644 --- a/Tests/WebAuthnTests/Utils/TestModels/TestClientDataJSON.swift +++ b/Tests/WebAuthnTests/Utils/TestModels/TestClientDataJSON.swift @@ -17,7 +17,7 @@ import WebAuthn struct TestClientDataJSON: Encodable { var type = "webauthn.create" - var challenge = TestConstants.mockChallenge + var challenge: URLEncodedBase64 = TestConstants.mockChallenge.base64URLEncodedString() var origin = "https://example.com" var crossOrigin = false var randomOtherKey = "123" @@ -26,8 +26,16 @@ struct TestClientDataJSON: Encodable { jsonData.base64URLEncodedString() } + /// Returns this `TestClientDataJSON` as encoded json. On **Linux** this is NOT idempotent. Subsequent calls + /// will result in different `Data` var jsonData: Data { // swiftlint:disable:next force_try try! JSONEncoder().encode(self) } + + /// Returns this `TestClientDataJSON` as encoded json. On **Linux** this is NOT idempotent. Subsequent calls + /// will result in different bytes + var jsonBytes: [UInt8] { + [UInt8](jsonData) + } } diff --git a/Tests/WebAuthnTests/Utils/TestModels/TestConstants.swift b/Tests/WebAuthnTests/Utils/TestModels/TestConstants.swift index 12e569b..7f37f3d 100644 --- a/Tests/WebAuthnTests/Utils/TestModels/TestConstants.swift +++ b/Tests/WebAuthnTests/Utils/TestModels/TestConstants.swift @@ -15,6 +15,7 @@ import WebAuthn struct TestConstants { - static var mockChallenge: URLEncodedBase64 = "cmFuZG9tU3RyaW5nRnJvbVNlcnZlcg" - static var mockCredentialID: URLEncodedBase64 = [0, 1, 2, 3, 4].base64URLEncodedString() + /// Byte representation of string "randomStringFromServer" + static var mockChallenge: [UInt8] = "72616e646f6d537472696e6746726f6d536572766572".hexadecimal! + static var mockCredentialID: [UInt8] = [0, 1, 2, 3, 4] } diff --git a/Tests/WebAuthnTests/Utils/TestModels/TestECCKeyPair.swift b/Tests/WebAuthnTests/Utils/TestModels/TestECCKeyPair.swift index 8434cf6..a227db9 100644 --- a/Tests/WebAuthnTests/Utils/TestModels/TestECCKeyPair.swift +++ b/Tests/WebAuthnTests/Utils/TestModels/TestECCKeyPair.swift @@ -39,7 +39,7 @@ struct TestECCKeyPair { return try privateKey.signature(for: data) } - static var signature: URLEncodedBase64 { + static var signature: [UInt8] { let authenticatorData = TestAuthDataBuilder() .validAuthenticationMock() // .counter([0, 0, 0, 1]) @@ -53,6 +53,6 @@ struct TestECCKeyPair { // swiftlint:disable:next force_try let signature = try! TestECCKeyPair.signature(data: signatureBase).derRepresentation - return signature.base64URLEncodedString() + return [UInt8](signature) } } diff --git a/Tests/WebAuthnTests/WebAuthnManagerAuthenticationTests.swift b/Tests/WebAuthnTests/WebAuthnManagerAuthenticationTests.swift index 9cea085..6b3ca02 100644 --- a/Tests/WebAuthnTests/WebAuthnManagerAuthenticationTests.swift +++ b/Tests/WebAuthnTests/WebAuthnManagerAuthenticationTests.swift @@ -21,14 +21,14 @@ final class WebAuthnManagerAuthenticationTests: XCTestCase { var webAuthnManager: WebAuthnManager! let challenge: [UInt8] = [1, 0, 1] - let relyingPartyDisplayName = "Testy test" let relyingPartyID = "example.com" + let relyingPartyName = "Testy test" let relyingPartyOrigin = "https://example.com" override func setUp() { - let config = WebAuthnConfig( - relyingPartyDisplayName: relyingPartyDisplayName, + let config = WebAuthnManager.Config( relyingPartyID: relyingPartyID, + relyingPartyName: relyingPartyName, relyingPartyOrigin: relyingPartyOrigin ) webAuthnManager = .init(config: config, challengeGenerator: .mock(generate: challenge)) @@ -42,7 +42,7 @@ final class WebAuthnManagerAuthenticationTests: XCTestCase { userVerification: .preferred ) - XCTAssertEqual(options.challenge, challenge.base64EncodedString()) + XCTAssertEqual(options.challenge, challenge) XCTAssertEqual(options.timeout, 1234000) // timeout converted to milliseconds XCTAssertEqual(options.rpId, relyingPartyID) XCTAssertEqual(options.allowCredentials, allowCredentials) @@ -56,31 +56,17 @@ final class WebAuthnManagerAuthenticationTests: XCTestCase { ) } - func testFinishAuthenticationFailsIfClientDataJSONIsNotBase64() throws { - try assertThrowsError( - finishAuthentication(clientDataJSON: "%"), - expect: WebAuthnError.invalidClientDataJSON - ) - } - func testFinishAuthenticationFailsIfClientDataJSONDecodingFails() throws { - try assertThrowsError(finishAuthentication(clientDataJSON: "abc")) { (_: DecodingError) in + try assertThrowsError(finishAuthentication(clientDataJSON: [0])) { (_: DecodingError) in return } } - func testFinishAuthenticationFailsIfAuthenticatorDataIsInvalid() throws { - try assertThrowsError( - finishAuthentication(authenticatorData: "%"), - expect: WebAuthnError.invalidAuthenticatorData - ) - } - func testFinishAuthenticationFailsIfCeremonyTypeDoesNotMatch() throws { var clientDataJSON = TestClientDataJSON() clientDataJSON.type = "webauthn.create" try assertThrowsError( - finishAuthentication(clientDataJSON: clientDataJSON.base64URLEncoded), + finishAuthentication(clientDataJSON: clientDataJSON.jsonBytes), expect: CollectedClientData.CollectedClientDataVerifyError.ceremonyTypeDoesNotMatch ) } @@ -91,7 +77,8 @@ final class WebAuthnManagerAuthenticationTests: XCTestCase { authenticatorData: TestAuthDataBuilder() .validAuthenticationMock() .rpIDHash(fromRpID: "wrong-id.org") - .buildAsBase64URLEncoded() + .build() + .byteArrayRepresentation ), expect: WebAuthnError.relyingPartyIDHashDoesNotMatch ) @@ -103,7 +90,8 @@ final class WebAuthnManagerAuthenticationTests: XCTestCase { authenticatorData: TestAuthDataBuilder() .validAuthenticationMock() .flags(0b10000000) - .buildAsBase64URLEncoded() + .build() + .byteArrayRepresentation ), expect: WebAuthnError.userPresentFlagNotSet ) @@ -115,7 +103,8 @@ final class WebAuthnManagerAuthenticationTests: XCTestCase { authenticatorData: TestAuthDataBuilder() .validAuthenticationMock() .flags(0b10000001) - .buildAsBase64URLEncoded(), + .build() + .byteArrayRepresentation, requireUserVerification: true ), expect: WebAuthnError.userVerifiedFlagNotSet @@ -128,7 +117,8 @@ final class WebAuthnManagerAuthenticationTests: XCTestCase { authenticatorData: TestAuthDataBuilder() .validAuthenticationMock() .counter([0, 0, 0, 1]) // signCount = 1 - .buildAsBase64URLEncoded(), + .build() + .byteArrayRepresentation, credentialCurrentSignCount: 2 ), expect: WebAuthnError.potentialReplayAttack @@ -142,46 +132,50 @@ final class WebAuthnManagerAuthenticationTests: XCTestCase { let authenticatorData = TestAuthDataBuilder() .validAuthenticationMock() .counter([0, 0, 0, 1]) - .buildAsBase64URLEncoded() + .build() + .byteArrayRepresentation // Create a signature. This part is usually performed by the authenticator - let clientData: Data = TestClientDataJSON(type: "webauthn.get").jsonData + + // ATTENTION: It is very important that we encode `TestClientDataJSON` only once!!! Subsequent calls to + // `jsonBytes` will result in different json (and thus the signature verification will fail) + // This has already cost me hours of troubleshooting twice + let clientData = TestClientDataJSON(type: "webauthn.get").jsonBytes let clientDataHash = SHA256.hash(data: clientData) - let rawAuthenticatorData = authenticatorData.urlDecoded.decoded! - let signatureBase = rawAuthenticatorData + clientDataHash + let signatureBase = Data(authenticatorData) + clientDataHash let signature = try TestECCKeyPair.signature(data: signatureBase).derRepresentation let verifiedAuthentication = try finishAuthentication( credentialID: credentialID, - clientDataJSON: clientData.base64URLEncodedString(), + clientDataJSON: clientData, authenticatorData: authenticatorData, - signature: signature.base64URLEncodedString(), + signature: [UInt8](signature), credentialCurrentSignCount: oldSignCount ) - XCTAssertEqual(verifiedAuthentication.credentialID, credentialID) + XCTAssertEqual(verifiedAuthentication.credentialID, credentialID.base64URLEncodedString()) XCTAssertEqual(verifiedAuthentication.newSignCount, oldSignCount + 1) } /// Using the default parameters `finishAuthentication` should succeed. private func finishAuthentication( - credentialID: URLEncodedBase64 = TestConstants.mockCredentialID, - clientDataJSON: URLEncodedBase64 = TestClientDataJSON(type: "webauthn.get").base64URLEncoded, - authenticatorData: URLEncodedBase64 = TestAuthDataBuilder().validAuthenticationMock() - .buildAsBase64URLEncoded(), - signature: URLEncodedBase64 = TestECCKeyPair.signature, - userHandle: String? = "NjI2OEJENkUtMDgxRS00QzExLUE3QzMtM0REMEFGMzNFQzE0", - attestationObject: String? = nil, + credentialID: [UInt8] = TestConstants.mockCredentialID, + clientDataJSON: [UInt8] = TestClientDataJSON(type: "webauthn.get").jsonBytes, + authenticatorData: [UInt8] = TestAuthDataBuilder().validAuthenticationMock().build().byteArrayRepresentation, + signature: [UInt8] = TestECCKeyPair.signature, + userHandle: [UInt8]? = "36323638424436452d303831452d344331312d413743332d334444304146333345433134".hexadecimal!, + attestationObject: [UInt8]? = nil, authenticatorAttachment: String? = "platform", type: String = "public-key", - expectedChallenge: URLEncodedBase64 = TestConstants.mockChallenge, + expectedChallenge: [UInt8] = TestConstants.mockChallenge, credentialPublicKey: [UInt8] = TestCredentialPublicKeyBuilder().validMock().buildAsByteArray(), credentialCurrentSignCount: UInt32 = 0, requireUserVerification: Bool = false ) throws -> VerifiedAuthentication { try webAuthnManager.finishAuthentication( credential: AuthenticationCredential( - id: credentialID, + id: credentialID.base64URLEncodedString(), + rawID: credentialID, response: AuthenticatorAssertionResponse( clientDataJSON: clientDataJSON, authenticatorData: authenticatorData, diff --git a/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift b/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift index 1648d49..fb4abba 100644 --- a/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift +++ b/Tests/WebAuthnTests/WebAuthnManagerIntegrationTests.swift @@ -19,9 +19,9 @@ import Crypto final class WebAuthnManagerIntegrationTests: XCTestCase { // swiftlint:disable:next function_body_length func testRegistrationAndAuthenticationSucceeds() async throws { - let config = WebAuthnConfig( - relyingPartyDisplayName: "Example RP", + let config = WebAuthnManager.Config( relyingPartyID: "example.com", + relyingPartyName: "Example RP", relyingPartyOrigin: "https://example.com" ) @@ -30,52 +30,52 @@ final class WebAuthnManagerIntegrationTests: XCTestCase { let webAuthnManager = WebAuthnManager(config: config, challengeGenerator: challengeGenerator) // Step 1.: Begin Registration - let mockUser = MockUser() + let mockUser = PublicKeyCredentialUserEntity.mock let timeout: TimeInterval = 1234 let attestationPreference = AttestationConveyancePreference.none - let publicKeyCredentialParameters = PublicKeyCredentialParameters.supported + let publicKeyCredentialParameters: [PublicKeyCredentialParameters] = .supported - let registrationOptions = try webAuthnManager.beginRegistration( + let registrationOptions = webAuthnManager.beginRegistration( user: mockUser, - timeout: timeout, + timeoutInSeconds: timeout, attestation: attestationPreference, publicKeyCredentialParameters: publicKeyCredentialParameters ) - XCTAssertEqual(registrationOptions.challenge, mockChallenge.base64EncodedString()) - XCTAssertEqual(registrationOptions.user.id, mockUser.userID.toBase64().asString()) + XCTAssertEqual(registrationOptions.challenge, mockChallenge) + XCTAssertEqual(registrationOptions.user.id, mockUser.id) XCTAssertEqual(registrationOptions.user.name, mockUser.name) XCTAssertEqual(registrationOptions.user.displayName, mockUser.displayName) XCTAssertEqual(registrationOptions.attestation, attestationPreference) - XCTAssertEqual(registrationOptions.rp.id, config.relyingPartyID) - XCTAssertEqual(registrationOptions.rp.name, config.relyingPartyDisplayName) - XCTAssertEqual(registrationOptions.timeout, timeout) - XCTAssertEqual(registrationOptions.pubKeyCredParams, publicKeyCredentialParameters) + XCTAssertEqual(registrationOptions.relyingParty.id, config.relyingPartyID) + XCTAssertEqual(registrationOptions.relyingParty.name, config.relyingPartyName) + XCTAssertEqual(registrationOptions.timeoutInMilliseconds, UInt32(timeout * 1000)) + XCTAssertEqual(registrationOptions.publicKeyCredentialParameters, publicKeyCredentialParameters) // Now send `registrationOptions` to client, which in turn will send the authenticator's response back to us: // The following lines reflect what an authenticator normally produces - let mockCredentialID = [UInt8](repeating: 1, count: 10).base64URLEncodedString() + let mockCredentialID = [UInt8](repeating: 1, count: 10) let mockClientDataJSON = TestClientDataJSON(challenge: mockChallenge.base64URLEncodedString()) let mockCredentialPublicKey = TestCredentialPublicKeyBuilder().validMock().buildAsByteArray() let mockAttestationObject = TestAttestationObjectBuilder().validMock().authData( TestAuthDataBuilder().validMock() .attestedCredData(credentialPublicKey: mockCredentialPublicKey) .noExtensionData() - ) + ).build().cborEncoded let registrationResponse = RegistrationCredential( - id: mockCredentialID.asString(), + id: mockCredentialID.base64URLEncodedString(), type: "public-key", rawID: mockCredentialID, attestationResponse: AuthenticatorAttestationResponse( - clientDataJSON: mockClientDataJSON.base64URLEncoded, - attestationObject: mockAttestationObject.buildBase64URLEncoded() + clientDataJSON: mockClientDataJSON.jsonBytes, + attestationObject: mockAttestationObject ) ) // Step 2.: Finish Registration let credential = try await webAuthnManager.finishRegistration( - challenge: mockChallenge.base64EncodedString(), + challenge: mockChallenge, credentialCreationData: registrationResponse, requireUserVerification: true, supportedPublicKeyAlgorithms: publicKeyCredentialParameters, @@ -83,7 +83,7 @@ final class WebAuthnManagerIntegrationTests: XCTestCase { confirmCredentialIDNotRegisteredYet: { _ in true } ) - XCTAssertEqual(credential.id, mockCredentialID.asString()) + XCTAssertEqual(credential.id, mockCredentialID.base64EncodedString().asString()) XCTAssertEqual(credential.attestationClientDataJSON.type, .create) XCTAssertEqual(credential.attestationClientDataJSON.origin, mockClientDataJSON.origin) XCTAssertEqual(credential.attestationClientDataJSON.challenge, mockChallenge.base64URLEncodedString()) @@ -101,7 +101,6 @@ final class WebAuthnManagerIntegrationTests: XCTestCase { )] let authenticationOptions = try webAuthnManager.beginAuthentication( - challenge: mockChallenge.base64EncodedString(), timeout: authenticationTimeout, allowCredentials: rememberedCredentials, userVerification: userVerification @@ -109,7 +108,7 @@ final class WebAuthnManagerIntegrationTests: XCTestCase { XCTAssertEqual(authenticationOptions.rpId, config.relyingPartyID) XCTAssertEqual(authenticationOptions.timeout, UInt32(authenticationTimeout * 1000)) // timeout is in milliseconds - XCTAssertEqual(authenticationOptions.challenge, mockChallenge.base64EncodedString()) + XCTAssertEqual(authenticationOptions.challenge, mockChallenge) XCTAssertEqual(authenticationOptions.userVerification, userVerification) XCTAssertEqual(authenticationOptions.allowCredentials, rememberedCredentials) @@ -118,25 +117,30 @@ final class WebAuthnManagerIntegrationTests: XCTestCase { let authenticatorData = TestAuthDataBuilder().validAuthenticationMock() .rpIDHash(fromRpID: config.relyingPartyID) .counter([0, 0, 0, 1]) // we authenticated once now, so authenticator likely increments the sign counter - .buildAsBase64URLEncoded() + .build() + .byteArrayRepresentation // Authenticator creates a signature with private key - let clientData: Data = TestClientDataJSON( + + // ATTENTION: It is very important that we encode `TestClientDataJSON` only once!!! Subsequent calls to + // `jsonBytes` will result in different json (and thus the signature verification will fail) + // This has already cost me hours of troubleshooting twice + let clientData = TestClientDataJSON( type: "webauthn.get", challenge: mockChallenge.base64URLEncodedString() - ).jsonData + ).jsonBytes let clientDataHash = SHA256.hash(data: clientData) - let rawAuthenticatorData = authenticatorData.urlDecoded.decoded! - let signatureBase = rawAuthenticatorData + clientDataHash + let signatureBase = Data(authenticatorData + clientDataHash) let signature = try TestECCKeyPair.signature(data: signatureBase).derRepresentation let authenticationCredential = AuthenticationCredential( - id: mockCredentialID, + id: mockCredentialID.base64URLEncodedString(), + rawID: mockCredentialID, response: AuthenticatorAssertionResponse( - clientDataJSON: clientData.base64URLEncodedString(), + clientDataJSON: clientData, authenticatorData: authenticatorData, - signature: signature.base64URLEncodedString(), - userHandle: mockUser.userID, + signature: [UInt8](signature), + userHandle: mockUser.id, attestationObject: nil ), authenticatorAttachment: "platform", @@ -147,7 +151,7 @@ final class WebAuthnManagerIntegrationTests: XCTestCase { let oldSignCount: UInt32 = 0 let successfullAuthentication = try webAuthnManager.finishAuthentication( credential: authenticationCredential, - expectedChallenge: mockChallenge.base64URLEncodedString(), + expectedChallenge: mockChallenge, credentialPublicKey: mockCredentialPublicKey, credentialCurrentSignCount: oldSignCount, requireUserVerification: false @@ -156,7 +160,7 @@ final class WebAuthnManagerIntegrationTests: XCTestCase { XCTAssertEqual(successfullAuthentication.newSignCount, 1) XCTAssertEqual(successfullAuthentication.credentialBackedUp, false) XCTAssertEqual(successfullAuthentication.credentialDeviceType, .singleDevice) - XCTAssertEqual(successfullAuthentication.credentialID, mockCredentialID) + XCTAssertEqual(successfullAuthentication.credentialID, mockCredentialID.base64URLEncodedString()) // We did it! } diff --git a/Tests/WebAuthnTests/WebAuthnManagerRegistrationTests.swift b/Tests/WebAuthnTests/WebAuthnManagerRegistrationTests.swift index d02acec..fe76a2f 100644 --- a/Tests/WebAuthnTests/WebAuthnManagerRegistrationTests.swift +++ b/Tests/WebAuthnTests/WebAuthnManagerRegistrationTests.swift @@ -26,9 +26,9 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { let relyingPartyOrigin = "https://example.com" override func setUp() { - let config = WebAuthnConfig( - relyingPartyDisplayName: relyingPartyDisplayName, + let config = WebAuthnManager.Config( relyingPartyID: relyingPartyID, + relyingPartyName: relyingPartyDisplayName, relyingPartyOrigin: relyingPartyOrigin ) webAuthnManager = .init(config: config, challengeGenerator: .mock(generate: challenge)) @@ -37,20 +37,20 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { // MARK: - beginRegistration() func testBeginRegistrationReturns() throws { - let user = MockUser() + let user = PublicKeyCredentialUserEntity.mock let publicKeyCredentialParameter = PublicKeyCredentialParameters(type: "public-key", alg: .algES256) - let options = try webAuthnManager.beginRegistration( + let options = webAuthnManager.beginRegistration( user: user, publicKeyCredentialParameters: [publicKeyCredentialParameter] ) - XCTAssertEqual(options.challenge, challenge.base64EncodedString()) - XCTAssertEqual(options.rp.id, relyingPartyID) - XCTAssertEqual(options.rp.name, relyingPartyDisplayName) - XCTAssertEqual(options.user.id, user.userID.toBase64().asString()) + XCTAssertEqual(options.challenge, challenge) + XCTAssertEqual(options.relyingParty.id, relyingPartyID) + XCTAssertEqual(options.relyingParty.name, relyingPartyDisplayName) + XCTAssertEqual(options.user.id, user.id) XCTAssertEqual(options.user.displayName, user.displayName) XCTAssertEqual(options.user.name, user.name) - XCTAssertEqual(options.pubKeyCredParams, [publicKeyCredentialParameter]) + XCTAssertEqual(options.publicKeyCredentialParameters, [publicKeyCredentialParameter]) } // MARK: - finishRegistration() @@ -59,18 +59,18 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { var clientDataJSON = TestClientDataJSON() clientDataJSON.type = "webauthn.get" try await assertThrowsError( - await finishRegistration(clientDataJSON: clientDataJSON.base64URLEncoded), + await finishRegistration(clientDataJSON: clientDataJSON.jsonBytes), expect: CollectedClientData.CollectedClientDataVerifyError.ceremonyTypeDoesNotMatch ) } func testFinishRegistrationFailsIfChallengeDoesNotMatch() async throws { var clientDataJSON = TestClientDataJSON() - clientDataJSON.challenge = "some random challenge" + clientDataJSON.challenge = [0, 2, 4].base64URLEncodedString() try await assertThrowsError( await finishRegistration( - challenge: "definitely another challenge", - clientDataJSON: clientDataJSON.base64URLEncoded + challenge: [UInt8]("definitely another challenge".utf8), + clientDataJSON: clientDataJSON.jsonBytes ), expect: CollectedClientData.CollectedClientDataVerifyError.challengeDoesNotMatch ) @@ -81,24 +81,11 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { clientDataJSON.origin = "https://random-origin.org" // `webAuthnManager` is configured with origin = https://example.com try await assertThrowsError( - await finishRegistration( - clientDataJSON: clientDataJSON.base64URLEncoded - ), + await finishRegistration(clientDataJSON: clientDataJSON.jsonBytes), expect: CollectedClientData.CollectedClientDataVerifyError.originDoesNotMatch ) } - func testFinishRegistrationFailsIfClientDataJSONIsInvalid() async throws { - try await assertThrowsError( - await finishRegistration(clientDataJSON: "%"), - expect: WebAuthnError.invalidClientDataJSON - ) - } - - func testFinishRegistrationFailsWithInvalidRawID() async throws { - try await assertThrowsError(await finishRegistration(rawID: "%"), expect: WebAuthnError.invalidRawID) - } - func testFinishRegistrationFailsWithInvalidCredentialCreationType() async throws { try await assertThrowsError( await finishRegistration(type: "foo"), @@ -106,22 +93,15 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { ) } - func testFinishRegistrationFailsWithInvalidClientDataJSON() async throws { - try await assertThrowsError( - await finishRegistration(clientDataJSON: "%%%"), - expect: WebAuthnError.invalidClientDataJSON - ) - } - func testFinishRegistrationFailsIfClientDataJSONDecodingFails() async throws { - try await assertThrowsError(await finishRegistration(clientDataJSON: "abc")) { (_: DecodingError) in + try await assertThrowsError(await finishRegistration(clientDataJSON: [0])) { (_: DecodingError) in return } } func testFinishRegistrationFailsIfAttestationObjectIsNotBase64() async throws { try await assertThrowsError( - await finishRegistration(attestationObject: "%%%"), + await finishRegistration(attestationObject: []), expect: WebAuthnError.invalidAttestationObject ) } @@ -132,7 +112,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { attestationObject: TestAttestationObjectBuilder() .validMock() .invalidAuthData() - .buildBase64URLEncoded() + .build() + .cborEncoded ), expect: WebAuthnError.invalidAuthData ) @@ -144,7 +125,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { attestationObject: TestAttestationObjectBuilder() .validMock() .invalidFmt() - .buildBase64URLEncoded() + .build() + .cborEncoded ), expect: WebAuthnError.invalidFmt ) @@ -156,7 +138,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { attestationObject: TestAttestationObjectBuilder() .validMock() .missingAttStmt() - .buildBase64URLEncoded() + .build() + .cborEncoded ), expect: WebAuthnError.missingAttStmt ) @@ -168,7 +151,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { attestationObject: TestAttestationObjectBuilder() .validMock() .zeroAuthData(byteCount: 36) - .buildBase64URLEncoded() + .build() + .cborEncoded ), expect: WebAuthnError.authDataTooShort ) @@ -186,7 +170,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { .noAttestedCredentialData() .noExtensionData() ) - .buildBase64URLEncoded() + .build() + .cborEncoded ), expect: WebAuthnError.attestedCredentialDataMissing ) @@ -203,7 +188,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { .flags(0b00000001) .attestedCredData(credentialPublicKey: []) ) - .buildBase64URLEncoded() + .build() + .cborEncoded ), expect: WebAuthnError.attestedCredentialFlagNotSet ) @@ -215,7 +201,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { attestationObject: TestAttestationObjectBuilder() .validMock() .authData(TestAuthDataBuilder().validMock().flags(0b11000001).noExtensionData()) - .buildBase64URLEncoded() + .build() + .cborEncoded ), expect: WebAuthnError.extensionDataMissing ) @@ -236,7 +223,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { ) .noExtensionData() ) - .buildBase64URLEncoded() + .build() + .cborEncoded ), expect: WebAuthnError.credentialIDTooShort ) @@ -248,7 +236,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { attestationObject: TestAttestationObjectBuilder() .validMock() .authData(TestAuthDataBuilder().validMock().rpIDHash(fromRpID: "invalid-id.com")) - .buildBase64URLEncoded() + .build() + .cborEncoded ), expect: WebAuthnError.relyingPartyIDHashDoesNotMatch ) @@ -260,7 +249,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { attestationObject: TestAttestationObjectBuilder() .validMock() .authData(TestAuthDataBuilder().validMock().flags(0b01000000)) - .buildBase64URLEncoded() + .build() + .cborEncoded ), expect: WebAuthnError.userPresentFlagNotSet ) @@ -272,7 +262,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { attestationObject: TestAttestationObjectBuilder() .validMock() .authData(TestAuthDataBuilder().validMock().flags(0b01000001)) - .buildBase64URLEncoded(), + .build() + .cborEncoded, requireUserVerification: true ), expect: WebAuthnError.userVerificationRequiredButFlagNotSet @@ -286,7 +277,8 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { .validMock() .fmt("none") .attStmt(.double(123)) - .buildBase64URLEncoded(), + .build() + .cborEncoded, requireUserVerification: true ), expect: WebAuthnError.attestationStatementMustBeEmpty @@ -295,13 +287,13 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { func testFinishRegistrationFailsIfRawIDIsTooLong() async throws { try await assertThrowsError( - await finishRegistration(rawID: [UInt8](repeating: 0, count: 1024).base64EncodedString().urlEncoded), + await finishRegistration(rawID: [UInt8](repeating: 0, count: 1024)), expect: WebAuthnError.credentialRawIDTooLong ) } func testFinishRegistrationSucceeds() async throws { - let credentialID = [0, 1, 0, 1, 0, 1].base64EncodedString() + let credentialID: [UInt8] = [0, 1, 0, 1, 0, 1] let credentialPublicKey: [UInt8] = TestCredentialPublicKeyBuilder().validMock().buildAsByteArray() let authData = TestAuthDataBuilder() .validMock() @@ -310,11 +302,16 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { let attestationObject = TestAttestationObjectBuilder() .validMock() .authData(authData) - .buildBase64URLEncoded() - let credential = try await finishRegistration(id: credentialID, attestationObject: attestationObject) + .build() + .cborEncoded + + let credential = try await finishRegistration( + rawID: credentialID, + attestationObject: attestationObject + ) XCTAssertNotNil(credential) - XCTAssertEqual(credential.id, credentialID.asString()) + XCTAssertEqual(credential.id, credentialID.base64EncodedString().asString()) XCTAssertEqual(credential.publicKey, credentialPublicKey) } @@ -332,19 +329,18 @@ final class WebAuthnManagerRegistrationTests: XCTestCase { // } private func finishRegistration( - challenge: EncodedBase64 = "cmFuZG9tU3RyaW5nRnJvbVNlcnZlcg", // "randomStringFromServer" - id: EncodedBase64 = "4PrJNQUJ9xdI2DeCzK9rTBRixhXHDiVdoTROQIh8j80", + challenge: [UInt8] = TestConstants.mockChallenge, type: String = "public-key", - rawID: URLEncodedBase64 = "4PrJNQUJ9xdI2DeCzK9rTBRixhXHDiVdoTROQIh8j80", - clientDataJSON: URLEncodedBase64 = TestClientDataJSON().base64URLEncoded, - attestationObject: URLEncodedBase64 = TestAttestationObjectBuilder().validMock().buildBase64URLEncoded(), + rawID: [UInt8] = "e0fac9350509f71748d83782ccaf6b4c1462c615c70e255da1344e40887c8fcd".hexadecimal!, + clientDataJSON: [UInt8] = TestClientDataJSON().jsonBytes, + attestationObject: [UInt8] = TestAttestationObjectBuilder().validMock().build().cborEncoded, requireUserVerification: Bool = false, confirmCredentialIDNotRegisteredYet: (String) async throws -> Bool = { _ in true } ) async throws -> Credential { try await webAuthnManager.finishRegistration( challenge: challenge, credentialCreationData: RegistrationCredential( - id: id.asString(), + id: rawID.base64URLEncodedString(), type: type, rawID: rawID, attestationResponse: AuthenticatorAttestationResponse(