Skip to content

Commit 7924fba

Browse files
authored
Add PBKDF2 password hashing (#74)
* Add PBKDF2 password hashing * Nit * Address review * Add docs * Format
1 parent ff4f55d commit 7924fba

5 files changed

Lines changed: 382 additions & 5 deletions

File tree

Package.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ let package = Package(
3333
traits: [
3434
.trait(name: "bcrypt"),
3535
.trait(name: "OTP"),
36+
.trait(name: "PBKDF2"),
3637
.default(enabledTraits: [
3738
"bcrypt",
3839
"OTP",
@@ -54,6 +55,7 @@ let package = Package(
5455
dependencies: [
5556
.target(name: "CVaporAuthBcrypt", condition: .when(traits: ["bcrypt"])),
5657
.product(name: "Crypto", package: "swift-crypto", condition: .when(traits: ["bcrypt", "OTP"])),
58+
.product(name: "CryptoExtras", package: "swift-crypto", condition: .when(traits: ["PBKDF2"])),
5759
],
5860
swiftSettings: extraSettings
5961
),

README.md

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ targets: [
3737

3838
## Password Hashing
3939

40-
Securely hash and verify user passwords using the bcrypt algorithm or the `PasswordHasher` algorithm:
40+
Securely hash and verify user passwords using the bcrypt algorithm, the `PasswordHasher` protocol or the PBKDF2 algorithm:
4141

4242
```swift
4343
import Authentication
4444

4545
// Create a hasher with default cost (12)
4646
let hasher = BcryptHasher()
47+
// Or use PBKDF2
48+
let hasher = PBKDF2Hasher()
4749
// Or a hasher injected in
4850
let hasher: PasswordHasher
4951

@@ -55,19 +57,35 @@ let isValid = try hasher.verify("secretPassword123", created: hash)
5557
// isValid == true
5658
```
5759

58-
### Configuring Cost
60+
### Configuration
61+
62+
#### Bcrypt
5963

6064
The cost parameter controls how computationally expensive the hashing operation is. Higher costs provide more security but take longer to compute:
6165

6266
```swift
63-
// Create a hasher with custom cost (valid range: 4-31)
67+
// Create a bcrypt hasher with custom cost (valid range: 4-31)
6468
let hasher = BcryptHasher(cost: 14)
6569

6670
let hash = try hasher.hash("myPassword")
6771
```
6872

6973
> **Note**: Increasing the cost by 1 doubles the computation time. A cost of 12 takes approximately 250ms on modern hardware.
7074
75+
#### PBKDF2
76+
77+
In PBKDF2 you can configure the number of iterations and hashing function. There are sensible standards in place already depending on the hash algorithm used, so only adjust the iterations if necessary:
78+
79+
```swift
80+
// Create a PBKDF2 hasher with custom iterations
81+
let hasher = PBKDF2Hasher(
82+
pseudoRandomFunction: .sha256,
83+
iterations: 600_000,
84+
)
85+
let hash = try hasher.hash("myPassword")
86+
```
87+
88+
7189
## One-Time Passwords (OTP)
7290

7391
Generate RFC-compliant HOTP and TOTP codes for multi-factor authentication.
@@ -137,4 +155,4 @@ let codes = totp.generate(time: Date(), range: 1)
137155

138156
// Check if user's code matches any valid code
139157
let isValid = codes.contains(userCode)
140-
```
158+
```

Sources/Authentication/Docs.docc/PasswordHashing.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ The Authentication library provides a robust password hashing system built on th
1010
- **Built-in salting**: Each hash includes a unique random salt
1111
- **Timing-safe comparison**: Prevents timing attacks during verification
1212

13+
If you prefer, you can also use the PBKDF2 algorithm for password hashing by utilizing the `PBKDF2Hasher`. PBKDF2 is a general key derivation function that is widely used for securely hashing passwords. It is considered less secure than bcrypt against modern hardware attacks.
14+
1315
### Basic Usage
1416

1517
#### ``PasswordHasher``
@@ -41,7 +43,27 @@ let isValid = try hasher.verify("secretPassword123", created: hash)
4143
// isValid == true
4244
```
4345

44-
### Configuring Cost
46+
#### ``PBKDF2Hasher``
47+
48+
Use ``PBKDF2Hasher`` to hash and verify passwords using the PBKDF2 algorithm:
49+
50+
```swift
51+
import Authentication
52+
53+
// Create a PBKDF2 hasher with default settings (SHA256, 600,000 iterations)
54+
let hasher = PBKDF2Hasher()
55+
56+
// Hash a password
57+
let hash = try hasher.hash("secretPassword123")
58+
59+
// Verify a password against a hash
60+
let isValid = try hasher.verify("secretPassword123", created: hash)
61+
// isValid == true
62+
```
63+
64+
### Configuration
65+
66+
#### Bcrypt
4567

4668
The cost parameter controls how computationally expensive the hashing operation is. Higher costs provide more security but take longer to compute. The default cost of 12 is suitable for most applications.
4769

@@ -54,6 +76,19 @@ let hash = try hasher.hash("myPassword")
5476

5577
> Important: Increasing the cost by 1 doubles the computation time. A cost of 12 takes approximately 250ms on modern hardware. Choose a cost that provides adequate security while maintaining acceptable response times for your users.
5678
79+
#### PBKDF2
80+
81+
In PBKDF2, you can configure the number of iterations and hashing function. There are sensible standards in place already depending on the hash algorithm used, so only adjust the iterations if necessary:
82+
83+
```swift
84+
// Create a PBKDF2 hasher with custom iterations
85+
let hasher = PBKDF2Hasher(
86+
pseudoRandomFunction: .sha256,
87+
iterations: 600_000,
88+
)
89+
let hash = try hasher.hash("myPassword")
90+
```
91+
5792
### Low-Level API
5893

5994
For more control, you can use the ``VaporBcrypt`` type directly:
@@ -68,6 +103,25 @@ let hash = try VaporBcrypt.hash("password", cost: 12)
68103
let isValid = try VaporBcrypt.verify("password", created: hash)
69104
```
70105

106+
Or, for PBKDF2,:
107+
108+
```swift
109+
import Authentication
110+
111+
// Hash with explicit parameters
112+
let hash = try PBKDF2Hasher.hash(
113+
Array("password".utf8),
114+
pseudoRandomFunction: .sha256,
115+
iterations: 600_000
116+
)
117+
118+
// Verify password
119+
let isValid = try PBKDF2Hasher.verify(
120+
Array("password".utf8),
121+
created: hash
122+
)
123+
```
124+
71125
### Testing with PlaintextHasher
72126

73127
For testing purposes, you can use ``PlaintextHasher`` which stores passwords without hashing:
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
#if PBKDF2
2+
import CryptoExtras
3+
4+
#if canImport(FoundationEssentials)
5+
public import FoundationEssentials
6+
#else
7+
public import Foundation
8+
#endif
9+
10+
/// A password hasher using PBKDF2 with configurable hash function and iterations.
11+
///
12+
/// The output format is a modular crypt format string:
13+
/// `$pbkdf2-<algorithm>$<iterations>$<base64-salt>$<base64-hash>`
14+
///
15+
/// This format is compatible with passlib and other common PBKDF2 implementations.
16+
/// See: https://passlib.readthedocs.io/en/stable/lib/passlib.hash.pbkdf2_digest.html
17+
public struct PBKDF2Hasher: PasswordHasher {
18+
let pseudoRandomFunction: HashFunction
19+
let outputByteCount: Int
20+
let iterations: Int
21+
22+
/// Creates a PBKDF2 password hasher.
23+
///
24+
/// - Parameters:
25+
/// - pseudoRandomFunction: The hash function to use. Defaults to SHA-256.
26+
/// - iterations: The number of PBKDF2 iterations. If nil, uses OWASP-recommended
27+
/// defaults based on the hash function.
28+
/// - Note: the parameters passed in here will only be used for hashing, verification
29+
/// will rely solely on the parameters inside of the hash.
30+
public init(
31+
pseudoRandomFunction: HashFunction = .sha256,
32+
iterations: Int? = nil
33+
) {
34+
self.pseudoRandomFunction = pseudoRandomFunction
35+
36+
// OWASP recommendations: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html#pbkdf2
37+
let defaultIterations: Int =
38+
switch pseudoRandomFunction {
39+
case .sha256: 600_000
40+
case .sha384: 400_000
41+
case .sha512: 210_000
42+
case .insecureSHA1: 1_300_000
43+
case .insecureSHA224: 800_000
44+
case .insecureMD5: 1_600_000
45+
}
46+
self.iterations = iterations ?? defaultIterations
47+
48+
self.outputByteCount =
49+
switch pseudoRandomFunction {
50+
case .sha256: 32
51+
case .sha384: 48
52+
case .sha512: 64
53+
case .insecureSHA224: 28
54+
case .insecureSHA1: 20
55+
case .insecureMD5: 16
56+
}
57+
}
58+
59+
/// Hashes a password using PBKDF2.
60+
///
61+
/// - Parameter password: The password to hash.
62+
/// - Returns: The hash string as UTF-8 bytes.
63+
public func hash<Password>(_ password: Password) throws -> [UInt8] where Password: DataProtocol {
64+
let salt = [UInt8].random(count: 16)
65+
let key = try KDF.Insecure.PBKDF2.deriveKey(
66+
from: password,
67+
salt: salt,
68+
using: pseudoRandomFunction.cryptoHashFunction,
69+
outputByteCount: outputByteCount,
70+
unsafeUncheckedRounds: iterations
71+
)
72+
73+
let keyData = unsafe key.withUnsafeBytes { unsafe Data($0) }
74+
75+
// $pbkdf2-<alg>$<iterations>$<b64salt>$<b64hash>
76+
let algorithmId = pseudoRandomFunction.rawValue
77+
let b64Salt = Data(salt).base64EncodedString()
78+
let b64Hash = keyData.base64EncodedString()
79+
80+
let passwordString = "$pbkdf2-\(algorithmId)$\(iterations)$\(b64Salt)$\(b64Hash)"
81+
return Array(passwordString.utf8)
82+
}
83+
84+
/// Verifies a password against a hash.
85+
///
86+
/// - Parameters:
87+
/// - password: The password to verify.
88+
/// - digest: The stored hash.
89+
/// - Returns: `true` if the password matches, `false` otherwise.
90+
public func verify<Password, Digest>(_ password: Password, created digest: Digest) throws -> Bool
91+
where Password: DataProtocol, Digest: DataProtocol {
92+
guard !digest.isEmpty else { return false }
93+
94+
let digestString = String(decoding: digest, as: UTF8.self)
95+
guard let parsed = Self.parsePassword(digestString), parsed.algorithm == pseudoRandomFunction else {
96+
return false
97+
}
98+
99+
let key = try KDF.Insecure.PBKDF2.deriveKey(
100+
from: password,
101+
salt: parsed.salt,
102+
using: parsed.algorithm.cryptoHashFunction,
103+
outputByteCount: parsed.hash.count,
104+
unsafeUncheckedRounds: parsed.iterations
105+
)
106+
107+
let keyData = unsafe key.withUnsafeBytes { unsafe Data($0) }
108+
109+
return keyData.elementsEqual(parsed.hash)
110+
}
111+
112+
private struct ParsedPassword {
113+
let algorithm: HashFunction
114+
let iterations: Int
115+
let salt: [UInt8]
116+
let hash: [UInt8]
117+
}
118+
119+
private static func parsePassword(_ string: String) -> ParsedPassword? {
120+
// Expected format: $pbkdf2-<alg>$<iterations>$<b64salt>$<b64hash>
121+
let parts = string.split(separator: "$", omittingEmptySubsequences: true)
122+
guard parts.count == 4 else { return nil }
123+
124+
// Parse algorithm
125+
let algPart = String(parts[0])
126+
guard
127+
algPart.hasPrefix("pbkdf2-"),
128+
let algorithm = HashFunction(rawValue: String(algPart.dropFirst(7)))
129+
else {
130+
return nil
131+
}
132+
133+
// Parse iterations
134+
guard let iterations = Int(parts[1]) else {
135+
return nil
136+
}
137+
138+
// Parse salt
139+
guard let saltData = Data(base64Encoded: String(parts[2])) else {
140+
return nil
141+
}
142+
143+
// Parse hash
144+
guard let hashData = Data(base64Encoded: String(parts[3])) else {
145+
return nil
146+
}
147+
148+
return ParsedPassword(
149+
algorithm: algorithm,
150+
iterations: iterations,
151+
salt: Array(saltData),
152+
hash: Array(hashData)
153+
)
154+
}
155+
156+
@nonexhaustive
157+
public enum HashFunction: String, Sendable {
158+
case insecureMD5 = "insecure_md5"
159+
case insecureSHA1 = "insecure_sha1"
160+
case insecureSHA224 = "insecure_sha224"
161+
case sha256 = "sha256"
162+
case sha384 = "sha384"
163+
case sha512 = "sha512"
164+
165+
var cryptoHashFunction: KDF.Insecure.PBKDF2.HashFunction {
166+
switch self {
167+
case .insecureMD5: .insecureMD5
168+
case .insecureSHA1: .insecureSHA1
169+
case .insecureSHA224: .insecureSHA224
170+
case .sha256: .sha256
171+
case .sha384: .sha384
172+
case .sha512: .sha512
173+
}
174+
}
175+
}
176+
}
177+
#endif

0 commit comments

Comments
 (0)