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
7 changes: 2 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,8 @@ jobs:
format_check_enabled: true
api_breakage_check_enabled: true
api_breakage_check_container_image: "swift:6.4-noble"
# Intentional pre-1.0 breaks; entries are removed once the next tag becomes the baseline.
api_breakage_check_allowlist_path: ".api-breakage-allowlist.txt"
docs_check_enabled: false
docs_check_analyze: false
docs_check_macos_analyze: false
docs_check_enabled: true
docs_check_container_image: "swift:6.4-noble"
license_header_check_enabled: false
unacceptable_language_check_enabled: false
broken_symlink_check_enabled: false
Expand Down
4 changes: 4 additions & 0 deletions .spi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
version: 1
builder:
configs:
- documentation_targets: [Bcrypt]
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func hash(
password: Span<UInt8>,
cost: Int = 10,
salt: Span<UInt8>,
version: BcryptVersion = .v2b,
version: BcryptVariant = .v2b,
into output: inout OutputSpan<UInt8>
) throws(BcryptError)
```
Expand All @@ -51,7 +51,7 @@ API, compared to Vapor's C bcrypt ([vapor/authentication](https://github.com/vap

A bcrypt hash starts with a version prefix, chosen with the `version` parameter when hashing. All three compute exactly the same hash from the same password and salt; they differ only in the prefix and in how passwords longer than 72 bytes are treated.

| Prefix | `BcryptVersion` | Origin | Passwords over 72 bytes |
| Prefix | `BcryptVariant` | Origin | Passwords over 72 bytes |
|--------|-----------------|--------|-------------------------|
| `$2b$` | `.v2b` (default) | OpenBSD, 2014 | Rejected with `passwordTooLong` |
| `$2y$` | `.v2y` | PHP `password_hash`, Apache `htpasswd` | Rejected with `passwordTooLong` |
Expand Down
53 changes: 53 additions & 0 deletions Sources/Bcrypt/Bcrypt.docc/Bcrypt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# ``Bcrypt``

A native, dependency-free Swift implementation of the bcrypt password hashing function.

## Overview

bcrypt is the password hashing function designed by Niels Provos and David Mazières for OpenBSD. It derives a key
from the password and a 128-bit salt with the EksBlowfish key schedule, whose cost grows exponentially with a
tunable work factor, and encodes the result as a 60-character string that carries its own version, cost and salt:

```
$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
\__/\/ \____________________/\_____________________________/
Alg Cost Salt Hash
```

This package is a port of the OpenBSD implementation. It produces hashes that are byte-for-byte interchangeable with OpenBSD, PHP, Apache `htpasswd` and every other conforming implementation.

### Hashing and verifying

```swift
import Bcrypt

let hash = try Bcrypt.hash(password: "correct horse battery staple", cost: 12)
// "$2b$12$..."

let ok = try Bcrypt.verify(password: "correct horse battery staple", against: hash)
// true
```

`hash` picks a random salt and returns the full 60-character string; store it as-is. `verify` reads the version, cost
and salt back out of the stored hash, recomputes it, and compares in constant time. Because the cost is stored in the
hash, you can raise the default for new passwords at any time and old hashes keep verifying.

Both functions have `String`, `[UInt8]` and `Span<UInt8>` overloads. The `Span` overloads and
``Bcrypt/Bcrypt/hash(password:cost:salt:version:into:)`` do not allocate.

### Errors

Every failure is a ``BcryptError`` describing a problem with the input. A password that simply does not match is not
an error; `verify` returns `false`.

## Topics

### Essentials

- ``Bcrypt/Bcrypt``
- <doc:SecurityConsiderations>

### Supporting types

- ``BcryptVariant``
- ``BcryptError``
39 changes: 39 additions & 0 deletions Sources/Bcrypt/Bcrypt.docc/SecurityConsiderations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Security Considerations

How this implementation handles versions, cost, randomness and memory.

## Overview

### Versions

A bcrypt hash starts with a version prefix, chosen with the `version` parameter when hashing. All three compute exactly the same hash from the same password and salt; they differ only in the prefix and in how passwords longer than 72 bytes are treated.

| Prefix | `BcryptVariant` | Origin | Passwords over 72 bytes |
|--------|-----------------|--------|-------------------------|
| `$2b$` | `.v2b` (default) | OpenBSD, 2014 | Rejected with `passwordTooLong` |
| `$2y$` | `.v2y` | PHP `password_hash`, Apache `htpasswd` | Rejected with `passwordTooLong` |
| `$2a$` | `.v2a` | Original 1999 revision | Silently truncated |

`verify` accepts any of the three and keeps the prefix. Use the default unless you need to emit hashes for a system that only accepts a specific prefix.

### Cost

The cost is the base-2 logarithm of the number of key schedule rounds, so every increment doubles the time to hash and to verify. It ranges from 4 to 31; on an Apple M5 Pro in release builds:

| Cost | Time |
|------|------|
| 4 | ~0.6 ms |
| 10 (default) | ~37 ms |
| 12 | ~150 ms |
| 14 | ~600 ms |
| 31 | ~22 hours |

At this time 12 is considered a reasonable default cost factor for balancing security and performance. The cost is stored on the hash so verification of old hashes continues to work even if the default cost is increased in the future.

### RNG

Bcrypt relies on a secure random number generator (RNG) to generate salts for hashing passwords. This package currently uses `SystemRandomNumberGenerator`.

### Zeroization

Zeroization is the process of securely erasing sensitive data from memory, usually by overwriting it with zeros or random data before deallocating the memory. After using it, bcrypt zeroizes the key state which is derived from the password to ensure that no sensitive information remains in memory. The password itself is never copied so wiping it is the caller's responsibility.
52 changes: 10 additions & 42 deletions Sources/Bcrypt/Bcrypt.swift
Original file line number Diff line number Diff line change
@@ -1,48 +1,16 @@
/// A native Swift implementation of the bcrypt password hashing function.
///
/// bcrypt derives a key from a password and a 128-bit salt with the EksBlowfish key schedule, whose cost
/// grows exponentially with the `cost` parameter, and encodes the result as a 60-character modular crypt
/// string:
///
/// ```
/// $2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
/// \__/\/ \____________________/\_____________________________/
/// Alg Cost Salt Hash
/// ```
/// The namespace for hashing and verifying passwords with bcrypt.
///
/// Use ``hash(password:cost:version:)-(String,_,_)`` to hash a new password with a random salt, and
/// ``verify(password:against:)-(String,_)`` to check a password against a stored hash. Both have overloads
/// for `[UInt8]` and `Span<UInt8>`; the `Span` overloads and
/// ``hash(password:cost:salt:version:into:)`` do not allocate.
///
/// ## Password length
///
/// bcrypt only keys on the first 72 bytes of the password (UTF-8 bytes), so anything past
/// that is not considered. To make sure no password is silently weakened, `hash` throws
/// ``BcryptError/passwordTooLong`` for longer inputs when creating ``BcryptVersion/v2b`` or
/// ``BcryptVersion/v2y`` hashes. `verify` instead truncates to 72 bytes, as OpenBSD, PHP and most other
/// implementations do when hashing, so hashes created elsewhere from longer passwords keep verifying.
/// for `[UInt8]` and `Span<UInt8>`; the `Span` overloads and ``hash(password:cost:salt:version:into:)`` do not
/// allocate.
///
/// A trailing NUL byte in the password is treated as the C string terminator and not counted twice.
/// An empty password is rejected with ``BcryptError/emptyPassword``.
///
/// ## Cost
///
/// `cost` is the base-2 logarithm of the number of key schedule rounds and must be between 4 and 31.
/// Each increment doubles the time to hash and to verify; cost 12 takes around 150 ms on an Apple M5 Pro.
/// The default of 10 matches other implementations; 12 or more is recommended for new deployments.
///
/// ## Salts and randomness
///
/// Salts are 16 random bytes from `SystemRandomNumberGenerator`, which is backed by the platform's
/// cryptographic random source. Callers that supply their own salt must pass the 22-character
/// bcrypt-base64 encoding; the trailing bits that do not fit into 16 bytes are ignored and the salt
/// is re-encoded in the output.
///
/// ## Timing
/// ```swift
/// let hash = try Bcrypt.hash(password: "correct horse battery staple", cost: 12)
/// let ok = try Bcrypt.verify(password: "correct horse battery staple", against: hash)
/// ```
///
/// Verification compares the full recomputed hash against the stored one without early exit. Malformed
/// hashes are rejected before any key schedule work is done, so invalid input costs nothing. The expanded
/// Blowfish state is zeroed after each hash; the password itself is never copied, so clearing it is the
/// caller's responsibility.
/// Passwords are limited to 72 bytes when hashing and truncated to 72 bytes when verifying; the cost must be
/// between 4 and 31. See <doc:SecurityConsiderations> for the reasoning behind these and the other
/// security-relevant choices in this implementation.
public enum Bcrypt: Sendable {}
2 changes: 1 addition & 1 deletion Sources/Bcrypt/BcryptError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
case emptyPassword
/// The cost is outside the range 4...31, or the two cost digits in a hash are not decimal digits.
case invalidCost
/// The password is longer than 72 bytes and the version is ``BcryptVersion/v2b`` or ``BcryptVersion/v2y``.
/// The password is longer than 72 bytes and the version is ``BcryptVariant/v2b`` or ``BcryptVariant/v2y``.
///
/// Only thrown when creating a hash; `verify` truncates instead, see ``Bcrypt``.
case passwordTooLong
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/// All three variants compute the same hash from the same password and salt; they differ only in the
/// prefix and in how over-long passwords are treated. `verify` accepts any of them and preserves the
/// prefix, so a hash created with one variant round-trips unchanged.
public enum BcryptVersion: Equatable, Sendable {
public enum BcryptVariant: Equatable, Sendable {
/// `$2a$`, the original 1999 revision.
///
/// Passwords longer than 72 bytes are truncated rather than rejected, for compatibility with hashes
Expand Down
12 changes: 6 additions & 6 deletions Sources/Bcrypt/Hasher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ extension Bcrypt {
/// - Throws: ``BcryptError``
/// - Returns: the hashed password.
@inlinable
public static func hash(password: String, cost: Int = 10, version: BcryptVersion = .v2b) throws(BcryptError) -> String {
public static func hash(password: String, cost: Int = 10, version: BcryptVariant = .v2b) throws(BcryptError) -> String {
String(decoding: try hash(password: password.utf8Span.span, cost: cost, version: version), as: UTF8.self)
}

Expand All @@ -34,7 +34,7 @@ extension Bcrypt {
/// - Throws: ``BcryptError``
/// - Returns: the hashed password.
@inlinable
public static func hash(password: [UInt8], cost: Int = 10, version: BcryptVersion = .v2b) throws(BcryptError) -> [UInt8] {
public static func hash(password: [UInt8], cost: Int = 10, version: BcryptVariant = .v2b) throws(BcryptError) -> [UInt8] {
try hash(password: password.span, cost: cost, version: version)
}

Expand All @@ -46,7 +46,7 @@ extension Bcrypt {
/// - Throws: ``BcryptError``
/// - Returns: the hashed password.
@inlinable
public static func hash(password: Span<UInt8>, cost: Int = 10, version: BcryptVersion = .v2b) throws(BcryptError) -> [UInt8] {
public static func hash(password: Span<UInt8>, cost: Int = 10, version: BcryptVariant = .v2b) throws(BcryptError) -> [UInt8] {
let salt = Self.generateRandomSalt()
return try hash(password: password, cost: cost, salt: salt.span, version: version)
}
Expand All @@ -61,7 +61,7 @@ extension Bcrypt {
/// - Returns: the hashed password.
@inlinable
public static func hash(
password: [UInt8], cost: Int = 10, salt: [UInt8], version: BcryptVersion = .v2b
password: [UInt8], cost: Int = 10, salt: [UInt8], version: BcryptVariant = .v2b
) throws(BcryptError) -> [UInt8] {
try hash(password: password.span, cost: cost, salt: salt.span, version: version)
}
Expand All @@ -76,7 +76,7 @@ extension Bcrypt {
/// - Returns: the hashed password.
@inlinable
public static func hash(
password: Span<UInt8>, cost: Int = 10, salt: Span<UInt8>, version: BcryptVersion = .v2b
password: Span<UInt8>, cost: Int = 10, salt: Span<UInt8>, version: BcryptVariant = .v2b
) throws(BcryptError) -> [UInt8] {
try [UInt8](capacity: Self.hashSpace) { output throws(BcryptError) in
try hash(password: password, cost: cost, salt: salt, version: version, into: &output)
Expand All @@ -95,7 +95,7 @@ extension Bcrypt {
password: Span<UInt8>,
cost: Int = 10,
salt: Span<UInt8>,
version: BcryptVersion = .v2b,
version: BcryptVariant = .v2b,
into output: inout OutputSpan<UInt8>
) throws(BcryptError) {
guard salt.count == Self.saltSpace else {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Bcrypt/Verifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ extension Bcrypt {
throw BcryptError.invalidHash
}

guard let version = BcryptVersion(identifier: hash.extracting(0...3)) else {
guard let version = BcryptVariant(identifier: hash.extracting(0...3)) else {
throw BcryptError.invalidVersion
}

Expand Down
Loading