Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ let package = Package(
traits: [
.trait(name: "bcrypt"),
.trait(name: "OTP"),
.trait(name: "PBKDF2",),
.default(enabledTraits: [
"bcrypt",
"OTP",
Expand All @@ -54,6 +55,7 @@ let package = Package(
dependencies: [
.target(name: "CVaporAuthBcrypt", condition: .when(traits: ["bcrypt"])),
.product(name: "Crypto", package: "swift-crypto", condition: .when(traits: ["bcrypt", "OTP"])),
.product(name: "CryptoExtras", package: "swift-crypto", condition: .when(traits: ["PBKDF2"])),
],
swiftSettings: extraSettings
),
Expand Down
177 changes: 177 additions & 0 deletions Sources/Authentication/Passwords/PBKDF2Hasher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
#if PBKDF2
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: 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: 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.
///
/// - 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.cryptoHashFunction,
outputByteCount: outputByteCount,
unsafeUncheckedRounds: iterations
)

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

// $pbkdf2-<alg>$<iterations>$<b64salt>$<b64hash>
let algorithmId = pseudoRandomFunction.rawValue
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.cryptoHashFunction,
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: 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(rawValue: 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)
)
}

@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
126 changes: 126 additions & 0 deletions Tests/AuthenticationTests/PBKDF2Tests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +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")
}

@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
Loading