Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,15 @@ targets: [

## Password Hashing

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

```swift
import Authentication

// Create a hasher with default cost (12)
let hasher = BcryptHasher()
// Or use PBKDF2
let hasher = PBKDF2Hasher()
// Or a hasher injected in
let hasher: PasswordHasher

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

### Configuring Cost
### Configuration

#### Bcrypt

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

```swift
// Create a hasher with custom cost (valid range: 4-31)
// Create a bcrypt hasher with custom cost (valid range: 4-31)
let hasher = BcryptHasher(cost: 14)

let hash = try hasher.hash("myPassword")
```

> **Note**: Increasing the cost by 1 doubles the computation time. A cost of 12 takes approximately 250ms on modern hardware.

#### PBKDF2

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:

```swift
// Create a PBKDF2 hasher with custom iterations
let hasher = PBKDF2Hasher(
pseudoRandomFunction: .sha256,
iterations: 600_000,
)
let hash = try hasher.hash("myPassword")
```


## One-Time Passwords (OTP)

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

// Check if user's code matches any valid code
let isValid = codes.contains(userCode)
```
```
56 changes: 55 additions & 1 deletion Sources/Authentication/Docs.docc/PasswordHashing.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The Authentication library provides a robust password hashing system built on th
- **Built-in salting**: Each hash includes a unique random salt
- **Timing-safe comparison**: Prevents timing attacks during verification

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.

### Basic Usage

#### ``PasswordHasher``
Expand Down Expand Up @@ -41,7 +43,27 @@ let isValid = try hasher.verify("secretPassword123", created: hash)
// isValid == true
```

### Configuring Cost
#### ``PBKDF2Hasher``

Use ``PBKDF2Hasher`` to hash and verify passwords using the PBKDF2 algorithm:

```swift
import Authentication

// Create a PBKDF2 hasher with default settings (SHA256, 600,000 iterations)
let hasher = PBKDF2Hasher()

// Hash a password
let hash = try hasher.hash("secretPassword123")

// Verify a password against a hash
let isValid = try hasher.verify("secretPassword123", created: hash)
// isValid == true
```

### Configuration

#### Bcrypt

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.

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

> 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.

#### PBKDF2

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:

```swift
// Create a PBKDF2 hasher with custom iterations
let hasher = PBKDF2Hasher(
pseudoRandomFunction: .sha256,
iterations: 600_000,
)
let hash = try hasher.hash("myPassword")
```

### Low-Level API

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

Or, for PBKDF2,:

```swift
import Authentication

// Hash with explicit parameters
let hash = try PBKDF2Hasher.hash(
Array("password".utf8),
pseudoRandomFunction: .sha256,
iterations: 600_000
)

// Verify password
let isValid = try PBKDF2Hasher.verify(
Array("password".utf8),
created: hash
)
```

### Testing with PlaintextHasher

For testing purposes, you can use ``PlaintextHasher`` which stores passwords without hashing:
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
Loading
Loading