Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ let extraSettings: [SwiftSetting] = [
.enableUpcomingFeature("ExistentialAny"),
.enableUpcomingFeature("MemberImportVisibility"),
.enableUpcomingFeature("InternalImportsByDefault"),
// .treatAllWarnings(as: .error),
// .treatAllWarnings(as: .error),
.strictMemorySafety(),
.enableExperimentalFeature("SafeInteropWrappers"),
.unsafeFlags(["-Xcc", "-fexperimental-bounds-safety-attributes"]),
Expand Down Expand Up @@ -45,6 +45,7 @@ let package = Package(
dependencies: [
.target(name: "CVaporAuthBcrypt"),
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "CryptoExtras", package: "swift-crypto"),
],
swiftSettings: extraSettings
),
Expand Down
180 changes: 180 additions & 0 deletions Sources/Authentication/Passwords/PBKDF2Hasher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
public import CryptoExtras

#if canImport(FoundationEssentials)
public import FoundationEssentials
#else
public import Foundation
#endif

/// A password hasher using PBKDF2 with configurable hash function and iterations.
///
/// The output format is a modular crypt format string:
/// `$pbkdf2-<algorithm>$<iterations>$<base64-salt>$<base64-hash>`
///
/// 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: KDF.Insecure.PBKDF2.HashFunction
let outputByteCount: Int
let iterations: Int

/// 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: KDF.Insecure.PBKDF2.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
default: fatalError("Unsupported hash function")
}
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
default: fatalError("Unsupported hash function")
}
}

/// Hashes a password using PBKDF2.
///
/// - Parameter password: The password to hash.
/// - Returns: The hash string as UTF-8 bytes.
public func hash<Password>(_ 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,
outputByteCount: outputByteCount,
unsafeUncheckedRounds: iterations
)

let keyData = unsafe key.withUnsafeBytes { unsafe Data($0) }

// $pbkdf2-<alg>$<iterations>$<b64salt>$<b64hash>
let algorithmId = Self.algorithmIdentifier(for: pseudoRandomFunction)
let b64Salt = Data(salt).base64EncodedString()
let b64Hash = keyData.base64EncodedString()

let passwordString = "$pbkdf2-\(algorithmId)$\(iterations)$\(b64Salt)$\(b64Hash)"
return Array(passwordString.utf8)
}

/// 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, Digest>(_ 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
}

let key = try KDF.Insecure.PBKDF2.deriveKey(
from: password,
salt: parsed.salt,
using: parsed.algorithm,
outputByteCount: parsed.hash.count,
unsafeUncheckedRounds: parsed.iterations
)

let keyData = unsafe key.withUnsafeBytes { unsafe Data($0) }

return keyData.elementsEqual(parsed.hash)
}

private struct ParsedPassword {
let algorithm: KDF.Insecure.PBKDF2.HashFunction
let iterations: Int
let salt: [UInt8]
let hash: [UInt8]
}

private static func parsePassword(_ string: String) -> ParsedPassword? {
// Expected format: $pbkdf2-<alg>$<iterations>$<b64salt>$<b64hash>
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(from: String(algPart.dropFirst(7)))
else {
return nil
}

// Parse iterations
guard let iterations = Int(parts[1]) else {
return nil
}

// 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)
)
}

private static func algorithmIdentifier(for hashFunction: KDF.Insecure.PBKDF2.HashFunction) -> String {
switch hashFunction {
case .sha256: "sha256"
case .sha384: "sha384"
case .sha512: "sha512"
case .insecureSHA1: "sha1"
case .insecureSHA224: "sha224"
case .insecureMD5: "md5"
default: fatalError("Unsupported hash function")
}
}

private static func hashFunction(from identifier: String) -> KDF.Insecure.PBKDF2.HashFunction? {
switch identifier {
case "sha256": .sha256
case "sha384": .sha384
case "sha512": .sha512
case "sha1": .insecureSHA1
case "sha224": .insecureSHA224
case "md5": .insecureMD5
default: nil
}
}
}
124 changes: 124 additions & 0 deletions Tests/AuthenticationTests/PBKDF2Tests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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: [
KDF.Insecure.PBKDF2.HashFunction.sha256,
KDF.Insecure.PBKDF2.HashFunction.sha384,
KDF.Insecure.PBKDF2.HashFunction.sha512,
KDF.Insecure.PBKDF2.HashFunction.insecureSHA1,
KDF.Insecure.PBKDF2.HashFunction.insecureSHA224,
])
func allHashFunctions(hashFunction: KDF.Insecure.PBKDF2.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")
}
}
Loading