diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 873d4d8..4d12055 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..d86ee54 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [Bcrypt] diff --git a/README.md b/README.md index 32c04fd..f2e4624 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ func hash( password: Span, cost: Int = 10, salt: Span, - version: BcryptVersion = .v2b, + version: BcryptVariant = .v2b, into output: inout OutputSpan ) throws(BcryptError) ``` @@ -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` | diff --git a/Sources/Bcrypt/Bcrypt.docc/Bcrypt.md b/Sources/Bcrypt/Bcrypt.docc/Bcrypt.md new file mode 100644 index 0000000..f4977e9 --- /dev/null +++ b/Sources/Bcrypt/Bcrypt.docc/Bcrypt.md @@ -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` 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`` +- + +### Supporting types + +- ``BcryptVariant`` +- ``BcryptError`` diff --git a/Sources/Bcrypt/Bcrypt.docc/SecurityConsiderations.md b/Sources/Bcrypt/Bcrypt.docc/SecurityConsiderations.md new file mode 100644 index 0000000..7f4cef9 --- /dev/null +++ b/Sources/Bcrypt/Bcrypt.docc/SecurityConsiderations.md @@ -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. diff --git a/Sources/Bcrypt/Bcrypt.swift b/Sources/Bcrypt/Bcrypt.swift index 8d9ce4f..4b2be89 100644 --- a/Sources/Bcrypt/Bcrypt.swift +++ b/Sources/Bcrypt/Bcrypt.swift @@ -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`; 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`; 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 for the reasoning behind these and the other +/// security-relevant choices in this implementation. public enum Bcrypt: Sendable {} diff --git a/Sources/Bcrypt/BcryptError.swift b/Sources/Bcrypt/BcryptError.swift index 8561e08..a008f78 100644 --- a/Sources/Bcrypt/BcryptError.swift +++ b/Sources/Bcrypt/BcryptError.swift @@ -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 diff --git a/Sources/Bcrypt/BcryptVersion.swift b/Sources/Bcrypt/BcryptVariant.swift similarity index 97% rename from Sources/Bcrypt/BcryptVersion.swift rename to Sources/Bcrypt/BcryptVariant.swift index e1d5f28..3e04cf8 100644 --- a/Sources/Bcrypt/BcryptVersion.swift +++ b/Sources/Bcrypt/BcryptVariant.swift @@ -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 diff --git a/Sources/Bcrypt/Hasher.swift b/Sources/Bcrypt/Hasher.swift index b69d753..740dd4d 100644 --- a/Sources/Bcrypt/Hasher.swift +++ b/Sources/Bcrypt/Hasher.swift @@ -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) } @@ -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) } @@ -46,7 +46,7 @@ extension Bcrypt { /// - Throws: ``BcryptError`` /// - Returns: the hashed password. @inlinable - public static func hash(password: Span, cost: Int = 10, version: BcryptVersion = .v2b) throws(BcryptError) -> [UInt8] { + public static func hash(password: Span, 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) } @@ -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) } @@ -76,7 +76,7 @@ extension Bcrypt { /// - Returns: the hashed password. @inlinable public static func hash( - password: Span, cost: Int = 10, salt: Span, version: BcryptVersion = .v2b + password: Span, cost: Int = 10, salt: Span, 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) @@ -95,7 +95,7 @@ extension Bcrypt { password: Span, cost: Int = 10, salt: Span, - version: BcryptVersion = .v2b, + version: BcryptVariant = .v2b, into output: inout OutputSpan ) throws(BcryptError) { guard salt.count == Self.saltSpace else { diff --git a/Sources/Bcrypt/Verifier.swift b/Sources/Bcrypt/Verifier.swift index 36965d6..5dc85a7 100644 --- a/Sources/Bcrypt/Verifier.swift +++ b/Sources/Bcrypt/Verifier.swift @@ -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 }