diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 14c39b4..ded24d5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,9 +4,15 @@ updates: directory: "/" schedule: interval: "daily" - allow: - - dependency-type: all groups: dependencies: patterns: - "*" + - package-ecosystem: "swift" + directory: "/" + schedule: + interval: "daily" + groups: + dependencies: + patterns: + - "*" \ No newline at end of file diff --git a/.github/workflows/api-docs.yml b/.github/workflows/api-docs.yml index dcbd904..e532f54 100644 --- a/.github/workflows/api-docs.yml +++ b/.github/workflows/api-docs.yml @@ -9,9 +9,7 @@ permissions: jobs: build-and-deploy: - uses: vapor/api-docs/.github/workflows/build-and-deploy-docs-workflow.yml@main - secrets: inherit - with: - package_name: authentication - modules: Authentication - pathsToInvalidate: /authentication/* + uses: vapor/api-docs/.github/workflows/deploy.yml@main + secrets: inherit + with: + package: authentication diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 808abb8..5bf3846 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -21,3 +21,17 @@ jobs: with_wasm: true ios_scheme_name: authentication secrets: inherit + + submit-dependencies: + permissions: + contents: write + if: ${{ github.event_name == 'push' }} + uses: vapor/ci/.github/workflows/submit-deps.yml@main + secrets: inherit + + foundation-linking: + uses: vapor/ci/.github/workflows/check-foundation-linking.yml@main + permissions: + contents: read + with: + swift_image: swift:6.3-noble \ No newline at end of file diff --git a/.spi.yml b/.spi.yml index 46bbb2d..8d5ed61 100644 --- a/.spi.yml +++ b/.spi.yml @@ -2,4 +2,4 @@ version: 1 metadata: authors: "Maintained by the Vapor Core Team with hundreds of contributions from the Vapor Community." external_links: - documentation: "https://api.vapor.codes/authentication/documentation/authentication/" + documentation: "https://api.vapor.codes/authentication" diff --git a/.swift-format b/.swift-format index c528621..5058afd 100644 --- a/.swift-format +++ b/.swift-format @@ -5,7 +5,7 @@ "indentation": { "spaces": 4 }, - "indentConditionalCompilationBlocks": false, + "indentConditionalCompilationBlocks": true, "indentSwitchCaseLabels": false, "lineBreakAroundMultilineExpressionChainComponents": false, "lineBreakBeforeControlFlowKeywords": false, @@ -14,7 +14,6 @@ "lineLength": 140, "maximumBlankLines": 1, "multiElementCollectionTrailingCommas": true, - "multilineTrailingCommaBehavior": "always", "noAssignmentInExpressions": { "allowedFunctions": [ "XCTAssertNoThrow" diff --git a/Package.swift b/Package.swift index 91f3566..c21d6dd 100644 --- a/Package.swift +++ b/Package.swift @@ -1,19 +1,21 @@ -// swift-tools-version:6.2.4 +// swift-tools-version:6.3 import PackageDescription let extraSettings: [SwiftSetting] = [ - .enableExperimentalFeature("SuppressedAssociatedTypes"), + // .treatAllWarnings(as: .error), + .strictMemorySafety(), .enableExperimentalFeature("LifetimeDependence"), + .enableExperimentalFeature("SuppressedAssociatedTypesWithDefaults"), + .enableExperimentalFeature("Lifetimes"), + .enableExperimentalFeature("SafeInteropWrappers"), .enableUpcomingFeature("LifetimeDependence"), .enableUpcomingFeature("NonisolatedNonsendingByDefault"), .enableUpcomingFeature("InferIsolatedConformances"), .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("MemberImportVisibility"), .enableUpcomingFeature("InternalImportsByDefault"), - // .treatAllWarnings(as: .error), - .strictMemorySafety(), - .enableExperimentalFeature("SafeInteropWrappers"), .unsafeFlags(["-Xcc", "-fexperimental-bounds-safety-attributes"]), + .enableUpcomingFeature("ImmutableWeakCaptures"), ] let package = Package( diff --git a/Sources/Authentication/Helpers/Data+Array.swift b/Sources/Authentication/Helpers/Data+Array.swift index c2f81b5..9bc4236 100644 --- a/Sources/Authentication/Helpers/Data+Array.swift +++ b/Sources/Authentication/Helpers/Data+Array.swift @@ -1,7 +1,7 @@ #if canImport(FoundationEssentials) -import FoundationEssentials + import FoundationEssentials #else -import Foundation + import Foundation #endif extension DataProtocol { diff --git a/Sources/Authentication/OTP/OTP.swift b/Sources/Authentication/OTP/OTP.swift index 08f3bb9..bc998a4 100644 --- a/Sources/Authentication/OTP/OTP.swift +++ b/Sources/Authentication/OTP/OTP.swift @@ -1,289 +1,289 @@ #if OTP -public import Crypto + public import Crypto -#if canImport(FoundationEssentials) -public import FoundationEssentials -#else -public import Foundation -#endif + #if canImport(FoundationEssentials) + public import FoundationEssentials + #else + public import Foundation + #endif -#if canImport(Darwin) -internal import os -#elseif canImport(Bionic) -internal import Bionic -#elseif canImport(Glibc) -internal import Glibc -#elseif canImport(Musl) -internal import Musl -#elseif canImport(CRT) -internal import CRT -#elseif os(WASI) -internal import WASILibc -#endif + #if canImport(Darwin) + internal import os + #elseif canImport(Bionic) + internal import Bionic + #elseif canImport(Glibc) + internal import Glibc + #elseif canImport(Musl) + internal import Musl + #elseif canImport(CRT) + internal import CRT + #elseif os(WASI) + internal import WASILibc + #endif -/// Supported OTP output sizes. -public enum OTPDigits: Int, Sendable { - /// Six digits OTP. - case six = 6 - /// Seven digits OTP. - case seven = 7 - /// Eight digits OTP. - case eight = 8 + /// Supported OTP output sizes. + public enum OTPDigits: Int, Sendable { + /// Six digits OTP. + case six = 6 + /// Seven digits OTP. + case seven = 7 + /// Eight digits OTP. + case eight = 8 - /// Returns 10^digit. - fileprivate var pow: UInt32 { - switch self { - case .six: return 1_000_000 - case .seven: return 10_000_000 - case .eight: return 100_000_000 + /// Returns 10^digit. + fileprivate var pow: UInt32 { + switch self { + case .six: return 1_000_000 + case .seven: return 10_000_000 + case .eight: return 100_000_000 + } } } -} -/// Supported OTP digests. -public enum OTPDigest: Sendable { - /// The SHA-1 digest. - case sha1 - /// The SHA-256 digest. - case sha256 - /// The SHA-512 digest. - case sha512 -} + /// Supported OTP digests. + public enum OTPDigest: Sendable { + /// The SHA-1 digest. + case sha1 + /// The SHA-256 digest. + case sha256 + /// The SHA-512 digest. + case sha512 + } -internal protocol OTP { - /// The key used to calculate the HMAC. - var key: SymmetricKey { get } - /// The number of digits to generate. - var digits: OTPDigits { get } - /// A hash function used to calculate HMAC's. - var digest: OTPDigest { get } -} + internal protocol OTP { + /// The key used to calculate the HMAC. + var key: SymmetricKey { get } + /// The number of digits to generate. + var digits: OTPDigits { get } + /// A hash function used to calculate HMAC's. + var digest: OTPDigest { get } + } -extension OTP { - /// Generate the OTP based on a counter. - /// - Parameters: - /// - h: The hash function to use. - /// - counter: The counter to generate the OTP for. - /// - Returns: The generated OTP as `String`. - func generate( - _ h: H, - counter: UInt64 - ) -> String { - let hmac = Array( - HMAC.authenticationCode( - for: counter.bigEndian.data, - using: self.key - )) - // Get the last 4 bits of the HMAC for use as offset - let offset = Int((hmac.last ?? 0x00) & 0x0f) - // Convert to UInt32, removing MSB, then to String - let number = String( - ((UInt32(hmac[offset + 0] & 0x7f) << 24) | (UInt32(hmac[offset + 1]) << 16) | (UInt32(hmac[offset + 2]) << 8) - | UInt32(hmac[offset + 3])) % self.digits.pow) + extension OTP { + /// Generate the OTP based on a counter. + /// - Parameters: + /// - h: The hash function to use. + /// - counter: The counter to generate the OTP for. + /// - Returns: The generated OTP as `String`. + func generate( + _ h: H, + counter: UInt64 + ) -> String { + let hmac = Array( + HMAC.authenticationCode( + for: counter.bigEndian.data, + using: self.key + )) + // Get the last 4 bits of the HMAC for use as offset + let offset = Int((hmac.last ?? 0x00) & 0x0f) + // Convert to UInt32, removing MSB, then to String + let number = String( + ((UInt32(hmac[offset + 0] & 0x7f) << 24) | (UInt32(hmac[offset + 1]) << 16) | (UInt32(hmac[offset + 2]) << 8) + | UInt32(hmac[offset + 3])) % self.digits.pow) - return String(repeatElement("0", count: self.digits.rawValue - number.count)) + number - } + return String(repeatElement("0", count: self.digits.rawValue - number.count)) + number + } - /// Generates a range of OTP's. - /// - Note: This function will automatically wrap the counter by using integer overflow. - /// - Parameters: - /// - h: The hash function to use - /// - counter: The 'main' counter. - /// - range: The number of codes to generate in both the forward and backward direction. This number must be bigger than 0. - /// For example, if `range` is `2`, a total of `5` codes will be returned: The main code, the two codes prior to the main code and the two codes after the main code. - /// - Returns: All the generated OTP's in an array. - func generateOTPs( - _ h: H, counter: UInt64, - range: Int - ) -> [String] { - precondition(range > 0, "Cannot generate range of OTP's for range \(range). Range must be greater than 0") + /// Generates a range of OTP's. + /// - Note: This function will automatically wrap the counter by using integer overflow. + /// - Parameters: + /// - h: The hash function to use + /// - counter: The 'main' counter. + /// - range: The number of codes to generate in both the forward and backward direction. This number must be bigger than 0. + /// For example, if `range` is `2`, a total of `5` codes will be returned: The main code, the two codes prior to the main code and the two codes after the main code. + /// - Returns: All the generated OTP's in an array. + func generateOTPs( + _ h: H, counter: UInt64, + range: Int + ) -> [String] { + precondition(range > 0, "Cannot generate range of OTP's for range \(range). Range must be greater than 0") - return (-range...range).map { self.generate(h, counter: UInt64(Int64(counter) &+ Int64($0))) } - } + return (-range...range).map { self.generate(h, counter: UInt64(Int64(counter) &+ Int64($0))) } + } - /// Generate the HOTP based on the counter. - /// - Parameter counter: The counter to generate the HOTP for. - /// - Returns: The generated HOTP as `String`. - func _generate( - counter: UInt64 - ) -> String { - switch self.digest { - case .sha1: return generate(Insecure.SHA1(), counter: counter) - case .sha256: return generate(SHA256(), counter: counter) - case .sha512: return generate(SHA512(), counter: counter) + /// Generate the HOTP based on the counter. + /// - Parameter counter: The counter to generate the HOTP for. + /// - Returns: The generated HOTP as `String`. + func _generate( + counter: UInt64 + ) -> String { + switch self.digest { + case .sha1: return generate(Insecure.SHA1(), counter: counter) + case .sha256: return generate(SHA256(), counter: counter) + case .sha512: return generate(SHA512(), counter: counter) + } } - } - /// Generates several TOTP's for a range. - /// - Note: This function will automatically wrap the counter by using integer overflow. This might provide some odd behaviour when near the start time or near the max time. - /// - Parameters: - /// - counter: The 'main' counter. - /// - range: The number of codes to generate in both the forward and backward direction. This number must be bigger than 0. - /// For example, if `range` is `2`, a total of `5` codes will be returned: The main code, the two codes prior to the main code and the two codes after the main code. - /// - Returns: All the generated OTP's in an array. - func _generate( - counter: UInt64, - range: Int - ) -> [String] { - switch self.digest { - case .sha1: return generateOTPs(Insecure.SHA1(), counter: counter, range: range) - case .sha256: return generateOTPs(SHA256(), counter: counter, range: range) - case .sha512: return generateOTPs(SHA512(), counter: counter, range: range) + /// Generates several TOTP's for a range. + /// - Note: This function will automatically wrap the counter by using integer overflow. This might provide some odd behaviour when near the start time or near the max time. + /// - Parameters: + /// - counter: The 'main' counter. + /// - range: The number of codes to generate in both the forward and backward direction. This number must be bigger than 0. + /// For example, if `range` is `2`, a total of `5` codes will be returned: The main code, the two codes prior to the main code and the two codes after the main code. + /// - Returns: All the generated OTP's in an array. + func _generate( + counter: UInt64, + range: Int + ) -> [String] { + switch self.digest { + case .sha1: return generateOTPs(Insecure.SHA1(), counter: counter, range: range) + case .sha256: return generateOTPs(SHA256(), counter: counter, range: range) + case .sha512: return generateOTPs(SHA512(), counter: counter, range: range) + } } } -} -/// Create a one-time password using hash-based message authentication codes. -/// -/// let key = SymmetricKey(size: .bits128) -/// let code = HOTP.SHA1(key: key).generate(counter: 0) -/// print(code) "208503" -/// -/// See `TOTP` for time-based one-time passwords. -public struct HOTP: OTP, Sendable { - let key: SymmetricKey - let digits: OTPDigits - let digest: OTPDigest + /// Create a one-time password using hash-based message authentication codes. + /// + /// let key = SymmetricKey(size: .bits128) + /// let code = HOTP.SHA1(key: key).generate(counter: 0) + /// print(code) "208503" + /// + /// See `TOTP` for time-based one-time passwords. + public struct HOTP: OTP, Sendable { + let key: SymmetricKey + let digits: OTPDigits + let digest: OTPDigest - /// Initialize the HOTP object. - /// - Parameters: - /// - key: The key. - /// - digest: The digest to use. - /// - digits: The number of digits to generate. Defaults to six. - public init( - key: SymmetricKey, - digest: OTPDigest, - digits: OTPDigits = .six - ) { - self.key = key - self.digits = digits - self.digest = digest - } + /// Initialize the HOTP object. + /// - Parameters: + /// - key: The key. + /// - digest: The digest to use. + /// - digits: The number of digits to generate. Defaults to six. + public init( + key: SymmetricKey, + digest: OTPDigest, + digits: OTPDigits = .six + ) { + self.key = key + self.digits = digits + self.digest = digest + } - /// Generate the HOTP based on the counter. - /// - Parameter counter: The counter to generate the HOTP for. - /// - Returns: The generated HOTP as `String`. - public func generate( - counter: UInt64 - ) -> String { - _generate(counter: counter) - } + /// Generate the HOTP based on the counter. + /// - Parameter counter: The counter to generate the HOTP for. + /// - Returns: The generated HOTP as `String`. + public func generate( + counter: UInt64 + ) -> String { + _generate(counter: counter) + } - /// Generates several HOTP's for a range. - /// - Note: This function will automatically wrap the counter by using integer overflow. - /// - Parameters: - /// - counter: The 'main' counter. - /// - range: The number of codes to generate in both the forward and backward direction. This number must be bigger than 0. - /// For example, if `range` is `2`, a total of `5` codes will be returned: The main code, the two codes prior to the main code and the two codes after the main code. - /// - Returns: All the generated OTP's in an array. - public func generate( - counter: UInt64, - range: Int - ) -> [String] { - _generate(counter: counter, range: range) - } + /// Generates several HOTP's for a range. + /// - Note: This function will automatically wrap the counter by using integer overflow. + /// - Parameters: + /// - counter: The 'main' counter. + /// - range: The number of codes to generate in both the forward and backward direction. This number must be bigger than 0. + /// For example, if `range` is `2`, a total of `5` codes will be returned: The main code, the two codes prior to the main code and the two codes after the main code. + /// - Returns: All the generated OTP's in an array. + public func generate( + counter: UInt64, + range: Int + ) -> [String] { + _generate(counter: counter, range: range) + } - /// Compute the HOTP for the key and the counter. - /// - Parameters: - /// - key: The key to use. - /// - digest: The digest to use. - /// - digits: The number of digits to produce. Defaults to six. - /// - counter: The counter to generate the HOTP for. - /// - Returns: The generated HOTP as `String`. - public static func generate( - key: SymmetricKey, - digest: OTPDigest, - digits: OTPDigits = .six, - counter: UInt64 - ) -> String { - return Self.init(key: key, digest: digest, digits: digits).generate(counter: counter) + /// Compute the HOTP for the key and the counter. + /// - Parameters: + /// - key: The key to use. + /// - digest: The digest to use. + /// - digits: The number of digits to produce. Defaults to six. + /// - counter: The counter to generate the HOTP for. + /// - Returns: The generated HOTP as `String`. + public static func generate( + key: SymmetricKey, + digest: OTPDigest, + digits: OTPDigits = .six, + counter: UInt64 + ) -> String { + return Self.init(key: key, digest: digest, digits: digits).generate(counter: counter) + } } -} -/// Create a one-time password using hash-based message authentication codes -/// and taking uniqueness from the time. -/// -/// let key = SymmetricKey(size: .bits128) -/// let code = TOTP.SHA1(key: key).generate(time: Date()) -/// print(code) "501247" -/// -/// See `HOTP` for hash-based one-time passwords. -public struct TOTP: OTP, Sendable { - let key: SymmetricKey - let digits: OTPDigits - let digest: OTPDigest - /// The time interval to generate the TOTP on. - let interval: Int + /// Create a one-time password using hash-based message authentication codes + /// and taking uniqueness from the time. + /// + /// let key = SymmetricKey(size: .bits128) + /// let code = TOTP.SHA1(key: key).generate(time: Date()) + /// print(code) "501247" + /// + /// See `HOTP` for hash-based one-time passwords. + public struct TOTP: OTP, Sendable { + let key: SymmetricKey + let digits: OTPDigits + let digest: OTPDigest + /// The time interval to generate the TOTP on. + let interval: Int - /// Initialize the TOTP object. - /// - Parameters: - /// - key: The key. - /// - digest: The digest to use. - /// - digits: The number of digits to generate. - /// - interval: The interval in seconds to generate the TOTP for. - public init( - key: SymmetricKey, - digest: OTPDigest, - digits: OTPDigits = .six, - interval: Int = 30 - ) { - precondition(interval > 0, "Cannot generate TOTP for invalid interval \(interval). Interval must be greater that 0") - self.key = key - self.digits = digits - self.digest = digest - self.interval = interval - } + /// Initialize the TOTP object. + /// - Parameters: + /// - key: The key. + /// - digest: The digest to use. + /// - digits: The number of digits to generate. + /// - interval: The interval in seconds to generate the TOTP for. + public init( + key: SymmetricKey, + digest: OTPDigest, + digits: OTPDigits = .six, + interval: Int = 30 + ) { + precondition(interval > 0, "Cannot generate TOTP for invalid interval \(interval). Interval must be greater that 0") + self.key = key + self.digits = digits + self.digest = digest + self.interval = interval + } - /// Generate the TOTP based on a time. - /// - Parameter time: The time to generate the TOTP for. - /// - Returns: The generated TOTP as `String`. - public func generate( - time: Date - ) -> String { - let counter = Int(floor(time.timeIntervalSince1970) / Double(self.interval)) - return _generate(counter: UInt64(counter)) - } + /// Generate the TOTP based on a time. + /// - Parameter time: The time to generate the TOTP for. + /// - Returns: The generated TOTP as `String`. + public func generate( + time: Date + ) -> String { + let counter = Int(floor(time.timeIntervalSince1970) / Double(self.interval)) + return _generate(counter: UInt64(counter)) + } - /// Generates several TOTP's for a range. - /// - Note: This function will automatically create the previous and next TOTP's for a range based on the interval. For example, if the interval is `30` and the range is `2`, the result will be calculated for `[-1min, -30sec, 0, 30sec, 1min]`. - /// - Note: This function will automatically wrap the counter by using integer overflow. This might provide some odd behaviour when near the start time or near the max time. - /// - Parameters: - /// - time: The time to generate the TOTP for. - /// - range: The number of codes to generate in both the forward and backward direction. This number must be bigger than 0. - /// For example, if `range` is `2`, a total of `5` codes will be returned: The main code, the two codes prior to the main code and the two codes after the main code. - /// - Returns: All the generated OTP's in an array. - public func generate( - time: Date, - range: Int - ) -> [String] { - let counter = Int(floor(time.timeIntervalSince1970) / Double(self.interval)) - return _generate(counter: UInt64(counter), range: range) - } + /// Generates several TOTP's for a range. + /// - Note: This function will automatically create the previous and next TOTP's for a range based on the interval. For example, if the interval is `30` and the range is `2`, the result will be calculated for `[-1min, -30sec, 0, 30sec, 1min]`. + /// - Note: This function will automatically wrap the counter by using integer overflow. This might provide some odd behaviour when near the start time or near the max time. + /// - Parameters: + /// - time: The time to generate the TOTP for. + /// - range: The number of codes to generate in both the forward and backward direction. This number must be bigger than 0. + /// For example, if `range` is `2`, a total of `5` codes will be returned: The main code, the two codes prior to the main code and the two codes after the main code. + /// - Returns: All the generated OTP's in an array. + public func generate( + time: Date, + range: Int + ) -> [String] { + let counter = Int(floor(time.timeIntervalSince1970) / Double(self.interval)) + return _generate(counter: UInt64(counter), range: range) + } - /// Compute the TOTP for the key, time interval and time. - /// - Parameters: - /// - key: The key. - /// - digest: The digest to use. - /// - digits: The number of digits to generate. - /// - interval: The interval in seconds to generate the TOTP for. - /// - time: The time to generate the TOTP for. - /// - Returns: The generated TOTP as `String`. - public static func generate( - key: SymmetricKey, - digest: OTPDigest, - digits: OTPDigits = .six, - interval: Int = 30, - time: Date - ) -> String { - return Self.init(key: key, digest: digest, digits: digits, interval: interval).generate(time: time) + /// Compute the TOTP for the key, time interval and time. + /// - Parameters: + /// - key: The key. + /// - digest: The digest to use. + /// - digits: The number of digits to generate. + /// - interval: The interval in seconds to generate the TOTP for. + /// - time: The time to generate the TOTP for. + /// - Returns: The generated TOTP as `String`. + public static func generate( + key: SymmetricKey, + digest: OTPDigest, + digits: OTPDigits = .six, + interval: Int = 30, + time: Date + ) -> String { + return Self.init(key: key, digest: digest, digits: digits, interval: interval).generate(time: time) + } } -} -extension FixedWidthInteger { - /// The raw data representing the integer. - fileprivate var data: Data { - var copy = self - return unsafe .init(bytes: ©, count: MemoryLayout.size) + extension FixedWidthInteger { + /// The raw data representing the integer. + fileprivate var data: Data { + var copy = self + return unsafe .init(bytes: ©, count: MemoryLayout.size) + } } -} #endif diff --git a/Sources/Authentication/Passwords/Bcrypt/BcryptError.swift b/Sources/Authentication/Passwords/Bcrypt/BcryptError.swift index 1137217..b062267 100644 --- a/Sources/Authentication/Passwords/Bcrypt/BcryptError.swift +++ b/Sources/Authentication/Passwords/Bcrypt/BcryptError.swift @@ -1,33 +1,33 @@ #if bcrypt -@nonexhaustive -public enum BcryptError: Swift.Error, CustomStringConvertible, Sendable { - case invalidCost - case invalidSalt - case hashFailure - case invalidHash - case internalError + @nonexhaustive + public enum BcryptError: Swift.Error, CustomStringConvertible, Sendable { + case invalidCost + case invalidSalt + case hashFailure + case invalidHash + case internalError - public var errorDescription: String? { - return self.description - } + public var errorDescription: String? { + return self.description + } - public var description: String { - return "bcrypt error: \(self.reason)" - } + public var description: String { + return "bcrypt error: \(self.reason)" + } - var reason: String { - switch self { - case .invalidCost: - return "Cost should be between 4 and 31" - case .invalidSalt: - return "Provided salt has the incorrect format" - case .hashFailure: - return "Unable to compute hash" - case .invalidHash: - return "Invalid hash formatting" - case .internalError: - return "Internal bcrypt error" + var reason: String { + switch self { + case .invalidCost: + return "Cost should be between 4 and 31" + case .invalidSalt: + return "Provided salt has the incorrect format" + case .hashFailure: + return "Unable to compute hash" + case .invalidHash: + return "Invalid hash formatting" + case .internalError: + return "Internal bcrypt error" + } } } -} #endif diff --git a/Sources/Authentication/Passwords/Bcrypt/BcryptHasher.swift b/Sources/Authentication/Passwords/Bcrypt/BcryptHasher.swift index c4db24d..b56fb89 100644 --- a/Sources/Authentication/Passwords/Bcrypt/BcryptHasher.swift +++ b/Sources/Authentication/Passwords/Bcrypt/BcryptHasher.swift @@ -1,34 +1,34 @@ #if bcrypt -#if canImport(FoundationEssentials) -public import FoundationEssentials -#else -public import Foundation -#endif + #if canImport(FoundationEssentials) + public import FoundationEssentials + #else + public import Foundation + #endif -public struct BcryptHasher: PasswordHasher { - let cost: Int - public init(cost: Int = 12) { - self.cost = cost - } + public struct BcryptHasher: PasswordHasher { + let cost: Int + public init(cost: Int = 12) { + self.cost = cost + } - public func hash( - _ password: Password - ) throws(BcryptError) -> [UInt8] - where Password: DataProtocol { - let string = String(decoding: password, as: UTF8.self) - let digest = try VaporBcrypt.hash(string, cost: self.cost) - return .init(digest.utf8) - } + public func hash( + _ password: Password + ) throws(BcryptError) -> [UInt8] + where Password: DataProtocol { + let string = String(decoding: password, as: UTF8.self) + let digest = try VaporBcrypt.hash(string, cost: self.cost) + return .init(digest.utf8) + } - public func verify( - _ password: Password, - created digest: Digest - ) throws(BcryptError) -> Bool - where Password: DataProtocol, Digest: DataProtocol { - try VaporBcrypt.verify( - String(decoding: password.copyBytes(), as: UTF8.self), - created: String(decoding: digest.copyBytes(), as: UTF8.self) - ) + public func verify( + _ password: Password, + created digest: Digest + ) throws(BcryptError) -> Bool + where Password: DataProtocol, Digest: DataProtocol { + try VaporBcrypt.verify( + String(decoding: password.copyBytes(), as: UTF8.self), + created: String(decoding: digest.copyBytes(), as: UTF8.self) + ) + } } -} #endif diff --git a/Sources/Authentication/Passwords/Bcrypt/VaporBcrypt.swift b/Sources/Authentication/Passwords/Bcrypt/VaporBcrypt.swift index 3292aa5..70434af 100644 --- a/Sources/Authentication/Passwords/Bcrypt/VaporBcrypt.swift +++ b/Sources/Authentication/Passwords/Bcrypt/VaporBcrypt.swift @@ -1,233 +1,233 @@ #if bcrypt -internal import CVaporAuthBcrypt + internal import CVaporAuthBcrypt -#if canImport(FoundationEssentials) -internal import FoundationEssentials -#else -internal import Foundation -#endif + #if canImport(FoundationEssentials) + internal import FoundationEssentials + #else + internal import Foundation + #endif -// MARK: bcrypt - -/// Creates and verifies bcrypt hashes. -/// -/// Use BcryptDigest to create hashes for sensitive information like passwords. -/// -/// try BcryptDigest.hash("vapor", cost: 12) -/// -/// bcrypt uses a random salt each time it creates a hash. To verify hashes, use the `verify(_:matches)` method. -/// -/// let hash = try BcryptDigest.hash("vapor", cost: 12) -/// try BcryptDigest.verify("vapor", created: hash) // true -/// -/// https://en.wikipedia.org/wiki/bcrypt -public enum VaporBcrypt: Sendable { - /// Creates a new bcrypt hash with a randomly generated salt. - /// The result can be stored in a database. - /// parameters: - /// - plaintext: Plaintext data to hash. - /// - cost: Desired complexity. Larger `cost` values take longer to hash and verify. Default is 12. - /// - throws: ``BcryptError`` if hashing fails or if data conversion fails. - /// - returns: Newly created bcrypt hash. - public static func hash(_ plaintext: String, cost: Int = 12) throws(BcryptError) -> String { - guard cost >= BCRYPT_MINLOGROUNDS && cost <= 31 else { - throw BcryptError.invalidCost - } - return try Self.hash(plaintext, salt: Self.generateSalt(cost: cost)) - } + // MARK: bcrypt - /// Creates a bcrypt hash using a provided salt. + /// Creates and verifies bcrypt hashes. /// - /// This method allows you to specify your own salt for hashing. The salt can be either: - /// - A 22-character raw salt (e.g., `J/dtt5ybYUTCJ/dtt5ybYO`) - /// - A 29-character full salt including algorithm and cost (e.g., `$2b$12$J/dtt5ybYUTCJ/dtt5ybYO`) + /// Use BcryptDigest to create hashes for sensitive information like passwords. /// - /// ```swift - /// let hash = try BcryptDigest.hash("vapor", salt: "$2b$12$J/dtt5ybYUTCJ/dtt5ybYO") - /// ``` + /// try BcryptDigest.hash("vapor", cost: 12) /// - /// > Important: For most use cases, prefer ``hash(_:cost:)`` which generates a secure random salt automatically. + /// bcrypt uses a random salt each time it creates a hash. To verify hashes, use the `verify(_:matches)` method. /// - /// - Parameters: - /// - plaintext: The plaintext string to hash. - /// - salt: A valid bcrypt salt (22 or 29 characters). - /// - Returns: The bcrypt hash string. - /// - Throws: ``BcryptError/invalidSalt`` if the salt format is invalid, or ``BcryptError/hashFailure`` if hashing fails. - public static func hash(_ plaintext: String, salt: String) throws(BcryptError) -> String { - guard isSaltValid(salt) else { - throw BcryptError.invalidSalt - } - - let originalAlgorithm: Algorithm - if salt.count == Algorithm.saltCount { - // user provided salt - originalAlgorithm = ._2b - } else { - // full salt, not user provided - let revisionString = String(salt.prefix(4)) - if let parsedRevision = Algorithm(rawValue: revisionString) { - originalAlgorithm = parsedRevision - } else { + /// let hash = try BcryptDigest.hash("vapor", cost: 12) + /// try BcryptDigest.verify("vapor", created: hash) // true + /// + /// https://en.wikipedia.org/wiki/bcrypt + public enum VaporBcrypt: Sendable { + /// Creates a new bcrypt hash with a randomly generated salt. + /// The result can be stored in a database. + /// parameters: + /// - plaintext: Plaintext data to hash. + /// - cost: Desired complexity. Larger `cost` values take longer to hash and verify. Default is 12. + /// - throws: ``BcryptError`` if hashing fails or if data conversion fails. + /// - returns: Newly created bcrypt hash. + public static func hash(_ plaintext: String, cost: Int = 12) throws(BcryptError) -> String { + guard cost >= BCRYPT_MINLOGROUNDS && cost <= 31 else { + throw BcryptError.invalidCost + } + return try Self.hash(plaintext, salt: Self.generateSalt(cost: cost)) + } + + /// Creates a bcrypt hash using a provided salt. + /// + /// This method allows you to specify your own salt for hashing. The salt can be either: + /// - A 22-character raw salt (e.g., `J/dtt5ybYUTCJ/dtt5ybYO`) + /// - A 29-character full salt including algorithm and cost (e.g., `$2b$12$J/dtt5ybYUTCJ/dtt5ybYO`) + /// + /// ```swift + /// let hash = try BcryptDigest.hash("vapor", salt: "$2b$12$J/dtt5ybYUTCJ/dtt5ybYO") + /// ``` + /// + /// > Important: For most use cases, prefer ``hash(_:cost:)`` which generates a secure random salt automatically. + /// + /// - Parameters: + /// - plaintext: The plaintext string to hash. + /// - salt: A valid bcrypt salt (22 or 29 characters). + /// - Returns: The bcrypt hash string. + /// - Throws: ``BcryptError/invalidSalt`` if the salt format is invalid, or ``BcryptError/hashFailure`` if hashing fails. + public static func hash(_ plaintext: String, salt: String) throws(BcryptError) -> String { + guard isSaltValid(salt) else { throw BcryptError.invalidSalt } - } - - // OpenBSD doesn't support 2y revision. - let normalizedSalt: String - if originalAlgorithm == Algorithm._2y { - // Replace with 2b. - normalizedSalt = Algorithm._2b.rawValue + salt.dropFirst(originalAlgorithm.revisionCount) - } else { - normalizedSalt = salt - } - - var hashData = [CChar](repeating: 0, count: 128) - var hashDataSpan = hashData.mutableSpan - let result = vapor_auth_bcrypt_hashpass(plaintext.utf8CString.span, normalizedSalt.utf8CString.span, &hashDataSpan) - guard result == 0 else { - throw BcryptError.hashFailure - } - // Remove null terminated characters - let cleanedHashData = Array(hashData.prefix { $0 != 0 }) - guard let string = String(validating: cleanedHashData, as: UTF8.self) else { - throw BcryptError.internalError - } - return originalAlgorithm.rawValue + string.dropFirst(originalAlgorithm.revisionCount) - } + let originalAlgorithm: Algorithm + if salt.count == Algorithm.saltCount { + // user provided salt + originalAlgorithm = ._2b + } else { + // full salt, not user provided + let revisionString = String(salt.prefix(4)) + if let parsedRevision = Algorithm(rawValue: revisionString) { + originalAlgorithm = parsedRevision + } else { + throw BcryptError.invalidSalt + } + } - /// Verifies an existing bcrypt hash matches the supplied plaintext value. Verification works by parsing the salt and version from - /// the existing digest and using that information to hash the plaintext data. If hash digests match, this method returns `true`. - /// - /// let hash = try BcryptDigest.hash("vapor", cost: 4) - /// try BcryptDigest.verify("vapor", created: hash) // true - /// try BcryptDigest.verify("foo", created: hash) // false - /// - /// - parameters: - /// - plaintext: Plaintext data to digest and verify. - /// - hash: Existing bcrypt hash to parse version, salt, and existing digest from. - /// - throws: `BcryptError` if hashing fails or if data conversion fails. - /// - returns: `true` if the hash was created from the supplied plaintext data. - public static func verify(_ plaintext: String, created hash: String) throws(BcryptError) -> Bool { - guard let hashVersion = Algorithm(rawValue: String(hash.prefix(4))) else { - throw BcryptError.invalidHash - } + // OpenBSD doesn't support 2y revision. + let normalizedSalt: String + if originalAlgorithm == Algorithm._2y { + // Replace with 2b. + normalizedSalt = Algorithm._2b.rawValue + salt.dropFirst(originalAlgorithm.revisionCount) + } else { + normalizedSalt = salt + } - let hashSalt = String(hash.prefix(hashVersion.fullSaltCount)) - guard !hashSalt.isEmpty, hashSalt.count == hashVersion.fullSaltCount else { - throw BcryptError.invalidHash - } + var hashData = [CChar](repeating: 0, count: 128) + var hashDataSpan = hashData.mutableSpan + let result = vapor_auth_bcrypt_hashpass(plaintext.utf8CString.span, normalizedSalt.utf8CString.span, &hashDataSpan) + guard result == 0 else { + throw BcryptError.hashFailure + } + // Remove null terminated characters + let cleanedHashData = Array(hashData.prefix { $0 != 0 }) + guard let string = String(validating: cleanedHashData, as: UTF8.self) else { + throw BcryptError.internalError + } - let hashChecksum = String(hash.suffix(hashVersion.checksumCount)) - guard !hashChecksum.isEmpty, hashChecksum.count == hashVersion.checksumCount else { - throw BcryptError.invalidHash - } + return originalAlgorithm.rawValue + string.dropFirst(originalAlgorithm.revisionCount) + } + + /// Verifies an existing bcrypt hash matches the supplied plaintext value. Verification works by parsing the salt and version from + /// the existing digest and using that information to hash the plaintext data. If hash digests match, this method returns `true`. + /// + /// let hash = try BcryptDigest.hash("vapor", cost: 4) + /// try BcryptDigest.verify("vapor", created: hash) // true + /// try BcryptDigest.verify("foo", created: hash) // false + /// + /// - parameters: + /// - plaintext: Plaintext data to digest and verify. + /// - hash: Existing bcrypt hash to parse version, salt, and existing digest from. + /// - throws: `BcryptError` if hashing fails or if data conversion fails. + /// - returns: `true` if the hash was created from the supplied plaintext data. + public static func verify(_ plaintext: String, created hash: String) throws(BcryptError) -> Bool { + guard let hashVersion = Algorithm(rawValue: String(hash.prefix(4))) else { + throw BcryptError.invalidHash + } - let messageHash = try Self.hash(plaintext, salt: hashSalt) - let messageHashChecksum = String(messageHash.suffix(hashVersion.checksumCount)) - return messageHashChecksum.secureCompare(to: hashChecksum) - } + let hashSalt = String(hash.prefix(hashVersion.fullSaltCount)) + guard !hashSalt.isEmpty, hashSalt.count == hashVersion.fullSaltCount else { + throw BcryptError.invalidHash + } - // MARK: Private + let hashChecksum = String(hash.suffix(hashVersion.checksumCount)) + guard !hashChecksum.isEmpty, hashChecksum.count == hashVersion.checksumCount else { + throw BcryptError.invalidHash + } - /// Generates string (29 chars total) containing the algorithm information + the cost + base-64 encoded 22 character salt - /// - /// E.g: $2b$05$J/dtt5ybYUTCJ/dtt5ybYO - /// $AA$ => Algorithm - /// $CC$ => Cost - /// SSSSSSSSSSSSSSSSSSSSSS => Salt - /// - /// Allowed charset for the salt: [./A-Za-z0-9] - /// - /// - parameters: - /// - cost: Desired complexity. Larger `cost` values take longer to hash and verify. - /// - algorithm: Revision to use (2b by default) - /// - seed: Salt (without revision data). Generated if not provided. Must be 16 chars long. - /// - returns: Complete salt - private static func generateSalt(cost: Int, algorithm: Algorithm = ._2b, seed: [UInt8]? = nil) throws(BcryptError) -> String { - let randomData: [UInt8] - if let seed = seed { - randomData = seed - } else { - randomData = [UInt8].random(count: 16) + let messageHash = try Self.hash(plaintext, salt: hashSalt) + let messageHashChecksum = String(messageHash.suffix(hashVersion.checksumCount)) + return messageHashChecksum.secureCompare(to: hashChecksum) + } + + // MARK: Private + + /// Generates string (29 chars total) containing the algorithm information + the cost + base-64 encoded 22 character salt + /// + /// E.g: $2b$05$J/dtt5ybYUTCJ/dtt5ybYO + /// $AA$ => Algorithm + /// $CC$ => Cost + /// SSSSSSSSSSSSSSSSSSSSSS => Salt + /// + /// Allowed charset for the salt: [./A-Za-z0-9] + /// + /// - parameters: + /// - cost: Desired complexity. Larger `cost` values take longer to hash and verify. + /// - algorithm: Revision to use (2b by default) + /// - seed: Salt (without revision data). Generated if not provided. Must be 16 chars long. + /// - returns: Complete salt + private static func generateSalt(cost: Int, algorithm: Algorithm = ._2b, seed: [UInt8]? = nil) throws(BcryptError) -> String { + let randomData: [UInt8] + if let seed = seed { + randomData = seed + } else { + randomData = [UInt8].random(count: 16) + } + let encodedSalt = try base64Encode(randomData) + return + algorithm.rawValue + (cost < 10 ? "0\(cost)" : "\(cost)") // 0 padded + + "$" + encodedSalt + } + + /// Checks whether the provided salt is valid or not + /// + /// - parameters: + /// - salt: Salt to be checked + /// - returns: True if the provided salt is valid + private static func isSaltValid(_ salt: String) -> Bool { + // Includes revision and cost info (count should be 29) + let revisionString = String(salt.prefix(4)) + if let algorithm = Algorithm(rawValue: revisionString) { + return salt.count == algorithm.fullSaltCount + } else { + // Does not include revision and cost info (count should be 22) + return salt.count == Algorithm.saltCount + } } - let encodedSalt = try base64Encode(randomData) - return - algorithm.rawValue + (cost < 10 ? "0\(cost)" : "\(cost)") // 0 padded - + "$" + encodedSalt - } - /// Checks whether the provided salt is valid or not - /// - /// - parameters: - /// - salt: Salt to be checked - /// - returns: True if the provided salt is valid - private static func isSaltValid(_ salt: String) -> Bool { - // Includes revision and cost info (count should be 29) - let revisionString = String(salt.prefix(4)) - if let algorithm = Algorithm(rawValue: revisionString) { - return salt.count == algorithm.fullSaltCount - } else { - // Does not include revision and cost info (count should be 22) - return salt.count == Algorithm.saltCount + /// Encodes the provided plaintext using OpenBSD's custom base-64 encoding (Radix-64) + /// + /// - parameters: + /// - data: Data to be base64 encoded. + /// - returns: Base 64 encoded plaintext + private static func base64Encode(_ data: [UInt8]) throws(BcryptError) -> String { + var encodedStringBytes = [CChar](repeating: 0, count: 25) + var span = encodedStringBytes.mutableSpan + let result = vapor_auth_encode_base64(&span, data.span) + // Remove null terminated characters + let cleanedString = Array(encodedStringBytes.prefix { $0 != 0 }) + guard result == 0, let encodedString = String(validating: cleanedString, as: UTF8.self) else { + throw BcryptError.internalError + } + return encodedString } - } - /// Encodes the provided plaintext using OpenBSD's custom base-64 encoding (Radix-64) - /// - /// - parameters: - /// - data: Data to be base64 encoded. - /// - returns: Base 64 encoded plaintext - private static func base64Encode(_ data: [UInt8]) throws(BcryptError) -> String { - var encodedStringBytes = [CChar](repeating: 0, count: 25) - var span = encodedStringBytes.mutableSpan - let result = vapor_auth_encode_base64(&span, data.span) - // Remove null terminated characters - let cleanedString = Array(encodedStringBytes.prefix { $0 != 0 }) - guard result == 0, let encodedString = String(validating: cleanedString, as: UTF8.self) else { - throw BcryptError.internalError - } - return encodedString - } + /// Specific bcrypt algorithm. + private enum Algorithm: String, RawRepresentable { + /// older version + case _2a = "$2a$" + /// format specific to the crypt_blowfish bcrypt implementation, identical to `2b` in all but name. + case _2y = "$2y$" + /// latest revision of the official bcrypt algorithm, current default + case _2b = "$2b$" - /// Specific bcrypt algorithm. - private enum Algorithm: String, RawRepresentable { - /// older version - case _2a = "$2a$" - /// format specific to the crypt_blowfish bcrypt implementation, identical to `2b` in all but name. - case _2y = "$2y$" - /// latest revision of the official bcrypt algorithm, current default - case _2b = "$2b$" - - /// Revision's length, including the `$` symbols - var revisionCount: Int { - return 4 - } + /// Revision's length, including the `$` symbols + var revisionCount: Int { + return 4 + } - /// Salt's length (includes revision and cost info) - var fullSaltCount: Int { - return 29 - } + /// Salt's length (includes revision and cost info) + var fullSaltCount: Int { + return 29 + } - /// Checksum's length - var checksumCount: Int { - return 31 - } + /// Checksum's length + var checksumCount: Int { + return 31 + } - /// Salt's length (does NOT include neither revision nor cost info) - static var saltCount: Int { - return 22 + /// Salt's length (does NOT include neither revision nor cost info) + static var saltCount: Int { + return 22 + } } } -} -extension Array where Element == UInt8 { - static func random(count: Int) -> [Element] { - var array: [Element] = .init(repeating: 0, count: count) - for index in 0.. [Element] { + var array: [Element] = .init(repeating: 0, count: count) + for index in 0..$$$` -/// -/// This format is compatible with passlib and other common PBKDF2 implementations. -/// See: https://passlib.readthedocs.io/en/stable/lib/passlib.hash.pbkdf2_digest.html -public struct PBKDF2Hasher: PasswordHasher { - let pseudoRandomFunction: HashFunction - let outputByteCount: Int - let iterations: Int - - /// Creates a PBKDF2 password hasher. + /// A password hasher using PBKDF2 with configurable hash function and iterations. /// - /// - Parameters: - /// - pseudoRandomFunction: The hash function to use. Defaults to SHA-256. - /// - iterations: The number of PBKDF2 iterations. If nil, uses OWASP-recommended - /// defaults based on the hash function. - /// - Note: the parameters passed in here will only be used for hashing, verification - /// will rely solely on the parameters inside of the hash. - public init( - pseudoRandomFunction: HashFunction = .sha256, - iterations: Int? = nil - ) { - self.pseudoRandomFunction = pseudoRandomFunction - - // OWASP recommendations: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 - let defaultIterations: Int = - switch pseudoRandomFunction { - case .sha256: 600_000 - case .sha384: 400_000 - case .sha512: 210_000 - case .insecureSHA1: 1_300_000 - case .insecureSHA224: 800_000 - case .insecureMD5: 1_600_000 - } - self.iterations = iterations ?? defaultIterations - - self.outputByteCount = - switch pseudoRandomFunction { - case .sha256: 32 - case .sha384: 48 - case .sha512: 64 - case .insecureSHA224: 28 - case .insecureSHA1: 20 - case .insecureMD5: 16 - } - } - - /// Hashes a password using PBKDF2. + /// The output format is a modular crypt format string: + /// `$pbkdf2-$$$` /// - /// - Parameter password: The password to hash. - /// - Returns: The hash string as UTF-8 bytes. - public func hash(_ password: Password) throws -> [UInt8] where Password: DataProtocol { - let salt = [UInt8].random(count: 16) - let key = try KDF.Insecure.PBKDF2.deriveKey( - from: password, - salt: salt, - using: pseudoRandomFunction.cryptoHashFunction, - outputByteCount: outputByteCount, - unsafeUncheckedRounds: iterations - ) - - let keyData = unsafe key.withUnsafeBytes { unsafe Data($0) } - - // $pbkdf2-$$$ - let algorithmId = pseudoRandomFunction.rawValue - let b64Salt = Data(salt).base64EncodedString() - let b64Hash = keyData.base64EncodedString() - - let passwordString = "$pbkdf2-\(algorithmId)$\(iterations)$\(b64Salt)$\(b64Hash)" - return Array(passwordString.utf8) - } + /// This format is compatible with passlib and other common PBKDF2 implementations. + /// See: https://passlib.readthedocs.io/en/stable/lib/passlib.hash.pbkdf2_digest.html + public struct PBKDF2Hasher: PasswordHasher { + let pseudoRandomFunction: HashFunction + let outputByteCount: Int + let iterations: Int - /// Verifies a password against a hash. - /// - /// - Parameters: - /// - password: The password to verify. - /// - digest: The stored hash. - /// - Returns: `true` if the password matches, `false` otherwise. - public func verify(_ password: Password, created digest: Digest) throws -> Bool - where Password: DataProtocol, Digest: DataProtocol { - guard !digest.isEmpty else { return false } - - let digestString = String(decoding: digest, as: UTF8.self) - guard let parsed = Self.parsePassword(digestString), parsed.algorithm == pseudoRandomFunction else { - return false + /// Creates a PBKDF2 password hasher. + /// + /// - Parameters: + /// - pseudoRandomFunction: The hash function to use. Defaults to SHA-256. + /// - iterations: The number of PBKDF2 iterations. If nil, uses OWASP-recommended + /// defaults based on the hash function. + /// - Note: the parameters passed in here will only be used for hashing, verification + /// will rely solely on the parameters inside of the hash. + public init( + pseudoRandomFunction: HashFunction = .sha256, + iterations: Int? = nil + ) { + self.pseudoRandomFunction = pseudoRandomFunction + + // OWASP recommendations: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2 + let defaultIterations: Int = + switch pseudoRandomFunction { + case .sha256: 600_000 + case .sha384: 400_000 + case .sha512: 210_000 + case .insecureSHA1: 1_300_000 + case .insecureSHA224: 800_000 + case .insecureMD5: 1_600_000 + } + self.iterations = iterations ?? defaultIterations + + self.outputByteCount = + switch pseudoRandomFunction { + case .sha256: 32 + case .sha384: 48 + case .sha512: 64 + case .insecureSHA224: 28 + case .insecureSHA1: 20 + case .insecureMD5: 16 + } } - let key = try KDF.Insecure.PBKDF2.deriveKey( - from: password, - salt: parsed.salt, - using: parsed.algorithm.cryptoHashFunction, - outputByteCount: parsed.hash.count, - unsafeUncheckedRounds: parsed.iterations - ) + /// Hashes a password using PBKDF2. + /// + /// - Parameter password: The password to hash. + /// - Returns: The hash string as UTF-8 bytes. + public func hash(_ password: Password) throws -> [UInt8] where Password: DataProtocol { + let salt = [UInt8].random(count: 16) + let key = try KDF.Insecure.PBKDF2.deriveKey( + from: password, + salt: salt, + using: pseudoRandomFunction.cryptoHashFunction, + outputByteCount: outputByteCount, + unsafeUncheckedRounds: iterations + ) + + let keyData = unsafe key.withUnsafeBytes { unsafe Data($0) } + + // $pbkdf2-$$$ + let algorithmId = pseudoRandomFunction.rawValue + let b64Salt = Data(salt).base64EncodedString() + let b64Hash = keyData.base64EncodedString() + + let passwordString = "$pbkdf2-\(algorithmId)$\(iterations)$\(b64Salt)$\(b64Hash)" + return Array(passwordString.utf8) + } - let keyData = unsafe key.withUnsafeBytes { unsafe Data($0) } + /// Verifies a password against a hash. + /// + /// - Parameters: + /// - password: The password to verify. + /// - digest: The stored hash. + /// - Returns: `true` if the password matches, `false` otherwise. + public func verify(_ password: Password, created digest: Digest) throws -> Bool + where Password: DataProtocol, Digest: DataProtocol { + guard !digest.isEmpty else { return false } + + let digestString = String(decoding: digest, as: UTF8.self) + guard let parsed = Self.parsePassword(digestString), parsed.algorithm == pseudoRandomFunction else { + return false + } - return keyData.elementsEqual(parsed.hash) - } + let key = try KDF.Insecure.PBKDF2.deriveKey( + from: password, + salt: parsed.salt, + using: parsed.algorithm.cryptoHashFunction, + outputByteCount: parsed.hash.count, + unsafeUncheckedRounds: parsed.iterations + ) - private struct ParsedPassword { - let algorithm: HashFunction - let iterations: Int - let salt: [UInt8] - let hash: [UInt8] - } + let keyData = unsafe key.withUnsafeBytes { unsafe Data($0) } - private static func parsePassword(_ string: String) -> ParsedPassword? { - // Expected format: $pbkdf2-$$$ - let parts = string.split(separator: "$", omittingEmptySubsequences: true) - guard parts.count == 4 else { return nil } - - // Parse algorithm - let algPart = String(parts[0]) - guard - algPart.hasPrefix("pbkdf2-"), - let algorithm = HashFunction(rawValue: String(algPart.dropFirst(7))) - else { - return nil + return keyData.elementsEqual(parsed.hash) } - // Parse iterations - guard let iterations = Int(parts[1]) else { - return nil + private struct ParsedPassword { + let algorithm: HashFunction + let iterations: Int + let salt: [UInt8] + let hash: [UInt8] } - // Parse salt - guard let saltData = Data(base64Encoded: String(parts[2])) else { - return nil - } + private static func parsePassword(_ string: String) -> ParsedPassword? { + // Expected format: $pbkdf2-$$$ + let parts = string.split(separator: "$", omittingEmptySubsequences: true) + guard parts.count == 4 else { return nil } + + // Parse algorithm + let algPart = String(parts[0]) + guard + algPart.hasPrefix("pbkdf2-"), + let algorithm = HashFunction(rawValue: String(algPart.dropFirst(7))) + else { + return nil + } - // Parse hash - guard let hashData = Data(base64Encoded: String(parts[3])) else { - return nil - } + // Parse iterations + guard let iterations = Int(parts[1]) else { + return nil + } - return ParsedPassword( - algorithm: algorithm, - iterations: iterations, - salt: Array(saltData), - hash: Array(hashData) - ) - } + // Parse salt + guard let saltData = Data(base64Encoded: String(parts[2])) else { + return nil + } + + // Parse hash + guard let hashData = Data(base64Encoded: String(parts[3])) else { + return nil + } + + return ParsedPassword( + algorithm: algorithm, + iterations: iterations, + salt: Array(saltData), + hash: Array(hashData) + ) + } - @nonexhaustive - public enum HashFunction: String, Sendable { - case insecureMD5 = "insecure_md5" - case insecureSHA1 = "insecure_sha1" - case insecureSHA224 = "insecure_sha224" - case sha256 = "sha256" - case sha384 = "sha384" - case sha512 = "sha512" - - var cryptoHashFunction: KDF.Insecure.PBKDF2.HashFunction { - switch self { - case .insecureMD5: .insecureMD5 - case .insecureSHA1: .insecureSHA1 - case .insecureSHA224: .insecureSHA224 - case .sha256: .sha256 - case .sha384: .sha384 - case .sha512: .sha512 + @nonexhaustive + public enum HashFunction: String, Sendable { + case insecureMD5 = "insecure_md5" + case insecureSHA1 = "insecure_sha1" + case insecureSHA224 = "insecure_sha224" + case sha256 = "sha256" + case sha384 = "sha384" + case sha512 = "sha512" + + var cryptoHashFunction: KDF.Insecure.PBKDF2.HashFunction { + switch self { + case .insecureMD5: .insecureMD5 + case .insecureSHA1: .insecureSHA1 + case .insecureSHA224: .insecureSHA224 + case .sha256: .sha256 + case .sha384: .sha384 + case .sha512: .sha512 + } } } } -} #endif diff --git a/Sources/Authentication/Passwords/PasswordHasher.swift b/Sources/Authentication/Passwords/PasswordHasher.swift index bbd021b..4c4d237 100644 --- a/Sources/Authentication/Passwords/PasswordHasher.swift +++ b/Sources/Authentication/Passwords/PasswordHasher.swift @@ -1,7 +1,7 @@ #if canImport(FoundationEssentials) -public import FoundationEssentials + public import FoundationEssentials #else -public import Foundation + public import Foundation #endif public protocol PasswordHasher: Sendable { diff --git a/Sources/Authentication/Passwords/PlaintextHasher.swift b/Sources/Authentication/Passwords/PlaintextHasher.swift index 0ae7924..732ac64 100644 --- a/Sources/Authentication/Passwords/PlaintextHasher.swift +++ b/Sources/Authentication/Passwords/PlaintextHasher.swift @@ -1,7 +1,7 @@ #if canImport(FoundationEssentials) -public import FoundationEssentials + public import FoundationEssentials #else -public import Foundation + public import Foundation #endif public struct PlaintextHasher: PasswordHasher { diff --git a/Tests/AuthenticationTests/BcryptTests.swift b/Tests/AuthenticationTests/BcryptTests.swift index 2e094ce..5a256e7 100644 --- a/Tests/AuthenticationTests/BcryptTests.swift +++ b/Tests/AuthenticationTests/BcryptTests.swift @@ -1,70 +1,70 @@ #if bcrypt -import Authentication -import Testing + import Authentication + import Testing -@Suite("bcrypt Tests") -struct BcryptTests { - @Test("Hash includes correct version prefix") - func version() throws { - let digest = try VaporBcrypt.hash("foo", cost: 6) - #expect(digest.hasPrefix("$2b$06$")) - } + @Suite("bcrypt Tests") + struct BcryptTests { + @Test("Hash includes correct version prefix") + func version() throws { + let digest = try VaporBcrypt.hash("foo", cost: 6) + #expect(digest.hasPrefix("$2b$06$")) + } - @Test("Verification fails for wrong password") - func verifyFails() throws { - let digest = try VaporBcrypt.hash("foo", cost: 6) - let result = try VaporBcrypt.verify("bar", created: digest) - #expect(result == false) - } + @Test("Verification fails for wrong password") + func verifyFails() throws { + let digest = try VaporBcrypt.hash("foo", cost: 6) + let result = try VaporBcrypt.verify("bar", created: digest) + #expect(result == false) + } - @Test("Invalid minimum cost throws error") - func invalidMinCost() { - #expect(throws: BcryptError.self) { - try VaporBcrypt.hash("foo", cost: 1) + @Test("Invalid minimum cost throws error") + func invalidMinCost() { + #expect(throws: BcryptError.self) { + try VaporBcrypt.hash("foo", cost: 1) + } } - } - @Test("Invalid maximum cost throws error") - func invalidMaxCost() { - #expect(throws: BcryptError.self) { - try VaporBcrypt.hash("foo", cost: 32) + @Test("Invalid maximum cost throws error") + func invalidMaxCost() { + #expect(throws: BcryptError.self) { + try VaporBcrypt.hash("foo", cost: 32) + } } - } - @Test("Invalid salt throws error") - func invalidSalt() { - #expect(throws: BcryptError.self) { - try VaporBcrypt.verify("", created: "foo") + @Test("Invalid salt throws error") + func invalidSalt() { + #expect(throws: BcryptError.self) { + try VaporBcrypt.verify("", created: "foo") + } } - } - @Test( - "Verify known hashes", - arguments: [ - (hash: "$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW", message: "U*U"), - (hash: "$2a$05$CCCCCCCCCCCCCCCCCCCCC.VGOzA784oUp/Z0DY336zx7pLYAy0lwK", message: "U*U*"), - (hash: "$2a$05$XXXXXXXXXXXXXXXXXXXXXOAcXxm9kjPGEMsLznoKqmqw7tc8WCx4a", message: "U*U*U"), - ( - hash: "$2a$05$abcdefghijklmnopqrstuu5s2v8.iXieOjg/.AySBTTZIIVFJeBui", - message: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789chars after 72 are ignored" - ), - (hash: "$2a$04$TI13sbmh3IHnmRepeEFoJOkVZWsn5S1O8QOwm8ZU5gNIpJog9pXZm", message: "vapor"), - (hash: "$2y$11$kHM/VXmCVsGXDGIVu9mD8eY/uEYI.Nva9sHgrLYuLzr0il28DDOGO", message: "Vapor3"), - (hash: "$2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s.", message: ""), - (hash: "$2a$06$m0CrhHm10qJ3lXRY.5zDGO3rS2KdeeWLuGmsfGlMfOxih58VYVfxe", message: "a"), - (hash: "$2a$06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i", message: "abc"), - (hash: "$2a$06$.rCVZVOThsIa97pEDOxvGuRRgzG64bvtJ0938xuqzv18d3ZpQhstC", message: "abcdefghijklmnopqrstuvwxyz"), - (hash: "$2a$06$fPIsBO8qRqkjj273rfaOI.HtSV9jLDpTbZn782DC6/t7qT67P6FfO", message: "~!@#$%^&*() ~!@#$%^&*()PNBFRD"), - ]) - func verify(hash: String, message: String) throws { - let result = try VaporBcrypt.verify(message, created: hash) - #expect(result, "\(message): did not match \(hash)") - } + @Test( + "Verify known hashes", + arguments: [ + (hash: "$2a$05$CCCCCCCCCCCCCCCCCCCCC.E5YPO9kmyuRGyh0XouQYb4YMJKvyOeW", message: "U*U"), + (hash: "$2a$05$CCCCCCCCCCCCCCCCCCCCC.VGOzA784oUp/Z0DY336zx7pLYAy0lwK", message: "U*U*"), + (hash: "$2a$05$XXXXXXXXXXXXXXXXXXXXXOAcXxm9kjPGEMsLznoKqmqw7tc8WCx4a", message: "U*U*U"), + ( + hash: "$2a$05$abcdefghijklmnopqrstuu5s2v8.iXieOjg/.AySBTTZIIVFJeBui", + message: "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789chars after 72 are ignored" + ), + (hash: "$2a$04$TI13sbmh3IHnmRepeEFoJOkVZWsn5S1O8QOwm8ZU5gNIpJog9pXZm", message: "vapor"), + (hash: "$2y$11$kHM/VXmCVsGXDGIVu9mD8eY/uEYI.Nva9sHgrLYuLzr0il28DDOGO", message: "Vapor3"), + (hash: "$2a$06$DCq7YPn5Rq63x1Lad4cll.TV4S6ytwfsfvkgY8jIucDrjc8deX1s.", message: ""), + (hash: "$2a$06$m0CrhHm10qJ3lXRY.5zDGO3rS2KdeeWLuGmsfGlMfOxih58VYVfxe", message: "a"), + (hash: "$2a$06$If6bvum7DFjUnE9p2uDeDu0YHzrHM6tf.iqN8.yx.jNN1ILEf7h0i", message: "abc"), + (hash: "$2a$06$.rCVZVOThsIa97pEDOxvGuRRgzG64bvtJ0938xuqzv18d3ZpQhstC", message: "abcdefghijklmnopqrstuvwxyz"), + (hash: "$2a$06$fPIsBO8qRqkjj273rfaOI.HtSV9jLDpTbZn782DC6/t7qT67P6FfO", message: "~!@#$%^&*() ~!@#$%^&*()PNBFRD"), + ]) + func verify(hash: String, message: String) throws { + let result = try VaporBcrypt.verify(message, created: hash) + #expect(result, "\(message): did not match \(hash)") + } - @Test("Verify known vapor hash") - func onlineVapor() throws { - let result = try VaporBcrypt.verify("vapor", created: "$2a$10$e.qg8zwKLHu3ur5rPF97ouzCJiJmZ93tiwNekDvTQfuhyu97QaUk.") - #expect(result) + @Test("Verify known vapor hash") + func onlineVapor() throws { + let result = try VaporBcrypt.verify("vapor", created: "$2a$10$e.qg8zwKLHu3ur5rPF97ouzCJiJmZ93tiwNekDvTQfuhyu97QaUk.") + #expect(result) + } } -} #endif diff --git a/Tests/AuthenticationTests/HashingTests.swift b/Tests/AuthenticationTests/HashingTests.swift index c99a11d..ced670f 100644 --- a/Tests/AuthenticationTests/HashingTests.swift +++ b/Tests/AuthenticationTests/HashingTests.swift @@ -4,20 +4,20 @@ import Testing @Suite("Password Tests") struct PasswordTests { #if bcrypt - @Test("BcryptHasher hashes and verifies passwords") - func bcryptHasher() throws { - let hash = try VaporBcrypt.hash("vapor") - #expect(hash != "vapor") - let hasher = BcryptHasher(cost: 12) - #expect(try hasher.verify("vapor", created: hash)) - #expect(try VaporBcrypt.verify("vapor", created: hash)) + @Test("BcryptHasher hashes and verifies passwords") + func bcryptHasher() throws { + let hash = try VaporBcrypt.hash("vapor") + #expect(hash != "vapor") + let hasher = BcryptHasher(cost: 12) + #expect(try hasher.verify("vapor", created: hash)) + #expect(try VaporBcrypt.verify("vapor", created: hash)) - let hash2 = try hasher.hash("vapor") - #expect(hash2 != hash) - #expect(hash2 != "vapor") - #expect(try hasher.verify("vapor", created: hash2)) - #expect(try VaporBcrypt.verify("vapor", created: hash2)) - } + let hash2 = try hasher.hash("vapor") + #expect(hash2 != hash) + #expect(hash2 != "vapor") + #expect(try hasher.verify("vapor", created: hash2)) + #expect(try VaporBcrypt.verify("vapor", created: hash2)) + } #endif @Test("PlaintextHasher hashes and verifies passwords") diff --git a/Tests/AuthenticationTests/OTPTests.swift b/Tests/AuthenticationTests/OTPTests.swift index 815a517..e1cd050 100644 --- a/Tests/AuthenticationTests/OTPTests.swift +++ b/Tests/AuthenticationTests/OTPTests.swift @@ -1,79 +1,83 @@ #if OTP -import Crypto -import Foundation -import Testing + import Crypto + #if canImport(FoundationEssentials) + import FoundationEssentials + #else + import Foundation + #endif + import Testing -@testable import Authentication + @testable import Authentication -@Suite("OTP Tests") -struct OTPTests { - /// Basic TOTP tests using some RFC 6238 test vectors. - /// https://tools.ietf.org/html/rfc6238.html - @Test( - "TOTP basic generation with RFC 6238 test vectors", - arguments: [ - (key: "12345678901234567890", digest: OTPDigest.sha1, otp: "94287082"), - (key: "12345678901234567890123456789012", digest: OTPDigest.sha256, otp: "46119246"), - (key: "1234567890123456789012345678901234567890123456789012345678901234", digest: OTPDigest.sha512, otp: "90693936"), - ]) - func totpBasic(key: String, digest: OTPDigest, otp: String) throws { - let time = Date(timeIntervalSince1970: 59) - let keyData = try #require(key.data(using: .ascii)) - let symmetricKey = SymmetricKey(data: keyData) - let result = TOTP(key: symmetricKey, digest: digest, digits: .eight, interval: 30).generate(time: time) - #expect(result == otp) - } + @Suite("OTP Tests") + struct OTPTests { + /// Basic TOTP tests using some RFC 6238 test vectors. + /// https://tools.ietf.org/html/rfc6238.html + @Test( + "TOTP basic generation with RFC 6238 test vectors", + arguments: [ + (key: "12345678901234567890", digest: OTPDigest.sha1, otp: "94287082"), + (key: "12345678901234567890123456789012", digest: OTPDigest.sha256, otp: "46119246"), + (key: "1234567890123456789012345678901234567890123456789012345678901234", digest: OTPDigest.sha512, otp: "90693936"), + ]) + func totpBasic(key: String, digest: OTPDigest, otp: String) throws { + let time = Date(timeIntervalSince1970: 59) + let keyData = try #require(key.data(using: .ascii)) + let symmetricKey = SymmetricKey(data: keyData) + let result = TOTP(key: symmetricKey, digest: digest, digits: .eight, interval: 30).generate(time: time) + #expect(result == otp) + } - @Test("TOTP range generation") - func totpRange() throws { - let time = Date(timeIntervalSince1970: 60) - let preTime = Date(timeIntervalSince1970: 30) - let postTime = Date(timeIntervalSince1970: 90) + @Test("TOTP range generation") + func totpRange() throws { + let time = Date(timeIntervalSince1970: 60) + let preTime = Date(timeIntervalSince1970: 30) + let postTime = Date(timeIntervalSince1970: 90) - let keyData = try #require("12345678901234567890".data(using: .ascii)) - let key = SymmetricKey(data: keyData) - let totp = TOTP(key: key, digest: .sha1, digits: .eight, interval: 30) - let codes = totp.generate(time: time, range: 1) - #expect(codes.count == 3) + let keyData = try #require("12345678901234567890".data(using: .ascii)) + let key = SymmetricKey(data: keyData) + let totp = TOTP(key: key, digest: .sha1, digits: .eight, interval: 30) + let codes = totp.generate(time: time, range: 1) + #expect(codes.count == 3) - let cur = totp.generate(time: time) - let pre = totp.generate(time: preTime) - let post = totp.generate(time: postTime) + let cur = totp.generate(time: time) + let pre = totp.generate(time: preTime) + let post = totp.generate(time: postTime) - #expect(Set([cur, pre, post]).count == 3) - #expect(codes.contains(totp.generate(time: time))) - #expect(codes.contains(totp.generate(time: preTime))) - #expect(codes.contains(totp.generate(time: postTime))) - } + #expect(Set([cur, pre, post]).count == 3) + #expect(codes.contains(totp.generate(time: time))) + #expect(codes.contains(totp.generate(time: preTime))) + #expect(codes.contains(totp.generate(time: postTime))) + } - /// Basic HOTP tests using RFC 4226 test vectors. - /// https://tools.ietf.org/html/rfc4226#page-32 - @Test( - "HOTP basic generation with RFC 4226 test vectors", - arguments: [ - (counter: 0, otp: "755224"), - (counter: 1, otp: "287082"), - (counter: 2, otp: "359152"), - (counter: 3, otp: "969429"), - (counter: 4, otp: "338314"), - (counter: 5, otp: "254676"), - (counter: 6, otp: "287922"), - (counter: 7, otp: "162583"), - (counter: 8, otp: "399871"), - (counter: 9, otp: "520489"), - ]) - func hotpBasic(counter: UInt64, otp: String) throws { - let keyData = try #require("12345678901234567890".data(using: .ascii)) - let key = SymmetricKey(data: keyData) - let hotp = HOTP(key: key, digest: .sha1).generate(counter: counter) - #expect(hotp == otp) - } + /// Basic HOTP tests using RFC 4226 test vectors. + /// https://tools.ietf.org/html/rfc4226#page-32 + @Test( + "HOTP basic generation with RFC 4226 test vectors", + arguments: [ + (counter: 0, otp: "755224"), + (counter: 1, otp: "287082"), + (counter: 2, otp: "359152"), + (counter: 3, otp: "969429"), + (counter: 4, otp: "338314"), + (counter: 5, otp: "254676"), + (counter: 6, otp: "287922"), + (counter: 7, otp: "162583"), + (counter: 8, otp: "399871"), + (counter: 9, otp: "520489"), + ]) + func hotpBasic(counter: UInt64, otp: String) throws { + let keyData = try #require("12345678901234567890".data(using: .ascii)) + let key = SymmetricKey(data: keyData) + let hotp = HOTP(key: key, digest: .sha1).generate(counter: counter) + #expect(hotp == otp) + } - @Test("HOTP range generation") - func hotpRange() { - let key = SymmetricKey(size: .bits128) - let codes = HOTP(key: key, digest: .sha1).generate(counter: 10, range: 1) - #expect(codes.count == 3) + @Test("HOTP range generation") + func hotpRange() { + let key = SymmetricKey(size: .bits128) + let codes = HOTP(key: key, digest: .sha1).generate(counter: 10, range: 1) + #expect(codes.count == 3) + } } -} #endif diff --git a/Tests/AuthenticationTests/PBKDF2Tests.swift b/Tests/AuthenticationTests/PBKDF2Tests.swift index c7286e6..ef9dad2 100644 --- a/Tests/AuthenticationTests/PBKDF2Tests.swift +++ b/Tests/AuthenticationTests/PBKDF2Tests.swift @@ -1,126 +1,126 @@ #if PBKDF2 -import Authentication -import CryptoExtras -import Testing - -@Suite("PBKDF2 Tests") -struct PBKDF2Tests { - @Test("Hash and verify round trip") - func hashAndVerify() throws { - let hasher = PBKDF2Hasher() - let password = "secretPassword123" - let digest = try hasher.hash(password) - let result = try hasher.verify(password, created: digest) - #expect(result, "Password should verify against its own hash") + import Authentication + import CryptoExtras + import Testing + + @Suite("PBKDF2 Tests") + struct PBKDF2Tests { + @Test("Hash and verify round trip") + func hashAndVerify() throws { + let hasher = PBKDF2Hasher() + let password = "secretPassword123" + let digest = try hasher.hash(password) + let result = try hasher.verify(password, created: digest) + #expect(result, "Password should verify against its own hash") + } + + @Test("Verification fails for wrong password") + func verifyFails() throws { + let hasher = PBKDF2Hasher() + let digest = try hasher.hash("correctPassword") + let result = try hasher.verify("wrongPassword", created: digest) + #expect(result == false) + } + + @Test("Empty digest returns false") + func emptyDigest() throws { + let hasher = PBKDF2Hasher() + let result = try hasher.verify("password", created: "") + #expect(result == false) + } + + @Test("Invalid digest format returns false") + func invalidDigestFormat() throws { + let hasher = PBKDF2Hasher() + // No separator + let result1 = try hasher.verify("password", created: "invaliddigest") + #expect(result1 == false) + + // Multiple separators + let result2 = try hasher.verify("password", created: "part1$part2$part3") + #expect(result2 == false) + } + + @Test("Invalid base64 in digest returns false") + func invalidBase64() throws { + let hasher = PBKDF2Hasher() + let result = try hasher.verify("password", created: "!!!invalid$###base64") + #expect(result == false) + } + + @Test("Different hash functions produce different outputs") + func differentHashFunctions() throws { + let sha256Hasher = PBKDF2Hasher(pseudoRandomFunction: .sha256) + let sha512Hasher = PBKDF2Hasher(pseudoRandomFunction: .sha512) + + let password = "testPassword" + let digest256 = try sha256Hasher.hash(password) + let digest512 = try sha512Hasher.hash(password) + + #expect(digest256 != digest512) + + #expect(try sha256Hasher.verify(password, created: digest256)) + #expect(try sha512Hasher.verify(password, created: digest512)) + + #expect(try sha256Hasher.verify(password, created: digest512) == false) + #expect(try sha512Hasher.verify(password, created: digest256) == false) + } + + @Test("Same password with different salts produces different hashes") + func differentSalts() throws { + let hasher = PBKDF2Hasher() + let password = "samePassword" + + let digest1 = try hasher.hash(password) + let digest2 = try hasher.hash(password) + + #expect(digest1 != digest2) + + #expect(try hasher.verify(password, created: digest1)) + #expect(try hasher.verify(password, created: digest2)) + } + + @Test("Empty password can be hashed and verified") + func emptyPassword() throws { + let hasher = PBKDF2Hasher() + let digest = try hasher.hash("") + let result = try hasher.verify("", created: digest) + #expect(result) + } + + @Test("Unicode passwords work correctly") + func unicodePassword() throws { + let hasher = PBKDF2Hasher() + let password = "пароль密码🔐" + let digest = try hasher.hash(password) + let result = try hasher.verify(password, created: digest) + #expect(result) + } + + @Test("Long password works correctly") + func longPassword() throws { + let hasher = PBKDF2Hasher() + let password = String(repeating: "a", count: 10000) + let digest = try hasher.hash(password) + let result = try hasher.verify(password, created: digest) + #expect(result) + } + + @Test( + "All supported hash functions work", + arguments: [ + PBKDF2Hasher.HashFunction.sha256, + PBKDF2Hasher.HashFunction.sha384, + PBKDF2Hasher.HashFunction.sha512, + PBKDF2Hasher.HashFunction.insecureSHA1, + PBKDF2Hasher.HashFunction.insecureSHA224, + ]) + func allHashFunctions(hashFunction: PBKDF2Hasher.HashFunction) throws { + let hasher = PBKDF2Hasher(pseudoRandomFunction: hashFunction, iterations: 1000) + let password = "testAllFunctions" + let digest = try hasher.hash(password) + let result = try hasher.verify(password, created: digest) + #expect(result, "Hash function should work for hashing and verification") + } } - - @Test("Verification fails for wrong password") - func verifyFails() throws { - let hasher = PBKDF2Hasher() - let digest = try hasher.hash("correctPassword") - let result = try hasher.verify("wrongPassword", created: digest) - #expect(result == false) - } - - @Test("Empty digest returns false") - func emptyDigest() throws { - let hasher = PBKDF2Hasher() - let result = try hasher.verify("password", created: "") - #expect(result == false) - } - - @Test("Invalid digest format returns false") - func invalidDigestFormat() throws { - let hasher = PBKDF2Hasher() - // No separator - let result1 = try hasher.verify("password", created: "invaliddigest") - #expect(result1 == false) - - // Multiple separators - let result2 = try hasher.verify("password", created: "part1$part2$part3") - #expect(result2 == false) - } - - @Test("Invalid base64 in digest returns false") - func invalidBase64() throws { - let hasher = PBKDF2Hasher() - let result = try hasher.verify("password", created: "!!!invalid$###base64") - #expect(result == false) - } - - @Test("Different hash functions produce different outputs") - func differentHashFunctions() throws { - let sha256Hasher = PBKDF2Hasher(pseudoRandomFunction: .sha256) - let sha512Hasher = PBKDF2Hasher(pseudoRandomFunction: .sha512) - - let password = "testPassword" - let digest256 = try sha256Hasher.hash(password) - let digest512 = try sha512Hasher.hash(password) - - #expect(digest256 != digest512) - - #expect(try sha256Hasher.verify(password, created: digest256)) - #expect(try sha512Hasher.verify(password, created: digest512)) - - #expect(try sha256Hasher.verify(password, created: digest512) == false) - #expect(try sha512Hasher.verify(password, created: digest256) == false) - } - - @Test("Same password with different salts produces different hashes") - func differentSalts() throws { - let hasher = PBKDF2Hasher() - let password = "samePassword" - - let digest1 = try hasher.hash(password) - let digest2 = try hasher.hash(password) - - #expect(digest1 != digest2) - - #expect(try hasher.verify(password, created: digest1)) - #expect(try hasher.verify(password, created: digest2)) - } - - @Test("Empty password can be hashed and verified") - func emptyPassword() throws { - let hasher = PBKDF2Hasher() - let digest = try hasher.hash("") - let result = try hasher.verify("", created: digest) - #expect(result) - } - - @Test("Unicode passwords work correctly") - func unicodePassword() throws { - let hasher = PBKDF2Hasher() - let password = "пароль密码🔐" - let digest = try hasher.hash(password) - let result = try hasher.verify(password, created: digest) - #expect(result) - } - - @Test("Long password works correctly") - func longPassword() throws { - let hasher = PBKDF2Hasher() - let password = String(repeating: "a", count: 10000) - let digest = try hasher.hash(password) - let result = try hasher.verify(password, created: digest) - #expect(result) - } - - @Test( - "All supported hash functions work", - arguments: [ - PBKDF2Hasher.HashFunction.sha256, - PBKDF2Hasher.HashFunction.sha384, - PBKDF2Hasher.HashFunction.sha512, - PBKDF2Hasher.HashFunction.insecureSHA1, - PBKDF2Hasher.HashFunction.insecureSHA224, - ]) - func allHashFunctions(hashFunction: PBKDF2Hasher.HashFunction) throws { - let hasher = PBKDF2Hasher(pseudoRandomFunction: hashFunction, iterations: 1000) - let password = "testAllFunctions" - let digest = try hasher.hash(password) - let result = try hasher.verify(password, created: digest) - #expect(result, "Hash function should work for hashing and verification") - } -} #endif