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
12 changes: 12 additions & 0 deletions Sources/Bcrypt/Bcrypt.docc/Bcrypt.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,18 @@ hash, you can raise the default for new passwords at any time and old hashes kee
Both functions have `String`, `[UInt8]` and `Span<UInt8>` overloads. The `Span` overloads and
``Bcrypt/Bcrypt/hash(password:cost:salt:version:into:)`` do not allocate.

### Settings strings

``Bcrypt/Bcrypt/hash(password:settings:)-(String,_)`` is the `crypt(3)`-style interface: instead of separate `cost`,
`salt` and `version` arguments it takes a *settings* string, the `$2x$NN$` prefix plus the 22-character salt, which is
exactly the first 29 characters of a hash. Passing the prefix of an existing hash recomputes that hash.

```swift
let stored = "$2b$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW"
let settings = String(stored.prefix(29))
let recomputed = try Bcrypt.hash(password: "password", settings: settings)
```

### Errors

Every failure is a ``BcryptError`` describing a problem with the input. A password that simply does not match is not
Expand Down
3 changes: 2 additions & 1 deletion Sources/Bcrypt/Bcrypt.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
/// 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.
/// allocate. ``hash(password:settings:)-(String,_)`` takes the version, cost and salt from a `crypt(3)`-style
/// settings string, the first 29 characters of a hash.
///
/// ```swift
/// let hash = try Bcrypt.hash(password: "correct horse battery staple", cost: 12)
Expand Down
6 changes: 5 additions & 1 deletion Sources/Bcrypt/BcryptError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
case invalidSalt
/// The hash passed to `verify` is not 60 characters long or does not have the `$2x$nn$` prefix layout.
case invalidHash
/// The settings passed to `hash(password:settings:)` are not 29 characters long or do not have the `$2x$nn$`
/// prefix layout.
case invalidSettings
/// The password is empty.
case emptyPassword
/// The cost is outside the range 4...31, or the two cost digits in a hash are not decimal digits.
Expand All @@ -14,6 +17,7 @@
///
/// Only thrown when creating a hash; `verify` truncates instead, see ``Bcrypt``.
case passwordTooLong
/// The hash passed to `verify` starts with a version prefix other than `$2a$`, `$2b$` or `$2y$`.
/// The hash passed to `verify`, or the settings passed to `hash(password:settings:)`, start with a version prefix
/// other than `$2a$`, `$2b$` or `$2y$`.
case invalidVersion
}
89 changes: 89 additions & 0 deletions Sources/Bcrypt/Settings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Ported from OpenBSD's bcrypt implementation (lib/libc/crypt/bcrypt.c).
// Copyright (c) 2014 Ted Unangst <tedu@openbsd.org>, Copyright (c) 1997 Niels Provos <provos@umich.edu>.
// Redistributed under the ISC license; the full notice is reproduced in LICENSE.

extension Bcrypt {
/// The length of a settings string: the 7 byte `$2x$NN$` prefix followed by the 22 character salt.
@usableFromInline static let settingsSpace = 29

/// Hashes a password using the bcrypt algorithm, taking the version, cost and salt from a settings string.
///
/// `settings` is the `$2x$NN$` prefix plus the 22-character salt, i.e. the first 29 bytes of a hash. This is
/// the `crypt(3)`-style interface; pass the first 29 characters of an existing hash to recompute it.
/// - Parameters:
/// - password: the password to hash.
/// - settings: the 29 character `$2x$NN$` prefix and salt to use for the hash.
/// - Throws: ``BcryptError``
/// - Returns: the hashed password.
@inlinable
public static func hash(password: String, settings: String) throws(BcryptError) -> String {
String(decoding: try hash(password: password.utf8Span.span, settings: settings.utf8Span.span), as: UTF8.self)
}

/// Hashes a password using the bcrypt algorithm, taking the version, cost and salt from a settings string.
///
/// `settings` is the `$2x$NN$` prefix plus the 22-character salt, i.e. the first 29 bytes of a hash. This is
/// the `crypt(3)`-style interface; pass the first 29 bytes of an existing hash to recompute it.
/// - Parameters:
/// - password: the password to hash.
/// - settings: the 29 byte `$2x$NN$` prefix and salt to use for the hash.
/// - Throws: ``BcryptError``
/// - Returns: the hashed password.
@inlinable
public static func hash(password: Span<UInt8>, settings: Span<UInt8>) throws(BcryptError) -> [UInt8] {
try [UInt8](capacity: Self.hashSpace) { output throws(BcryptError) in
try hash(password: password, settings: settings, into: &output)
}
}

/// Hashes a password using the bcrypt algorithm, taking the version, cost and salt from a settings string and
/// writing the 60 byte hash into `output`.
///
/// `settings` is the `$2x$NN$` prefix plus the 22-character salt, i.e. the first 29 bytes of a hash. This is
/// the `crypt(3)`-style interface; pass the first 29 bytes of an existing hash to recompute it.
/// - Parameters:
/// - password: the password to hash.
/// - settings: the 29 byte `$2x$NN$` prefix and salt to use for the hash.
/// - output: receives the 60 bytes of the hash. Must have room for at least 60 more elements.
/// - Throws: ``BcryptError``
@inlinable
public static func hash(
password: Span<UInt8>, settings: Span<UInt8>, into output: inout OutputSpan<UInt8>
) throws(BcryptError) {
guard settings.count == Self.settingsSpace else {
throw BcryptError.invalidSettings
}

let (version, cost) = try parseVersionAndCost(settings)

guard settings[6] == UInt8.separator else {
throw BcryptError.invalidSettings
}

try hash(password: password, cost: cost, salt: settings.extracting(7..<Self.settingsSpace), version: version, into: &output)
}

/// Reads the variant and cost out of the leading `$2x$NN` bytes of a hash or settings string.
///
/// `prefix` must be at least 6 bytes long. The separator that follows the cost is not checked, so that callers
/// can report a missing one as an invalid hash or invalid settings as appropriate.
/// - Throws: ``BcryptError/invalidVersion`` or ``BcryptError/invalidCost``.
@usableFromInline
static func parseVersionAndCost(_ prefix: Span<UInt8>) throws(BcryptError) -> (version: BcryptVariant, cost: Int) {
// $2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
// \__/\/ \____________________/\_____________________________/
// Alg Cost Salt Hash

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

let tens = Int(prefix[4]) - 48
let ones = Int(prefix[5]) - 48
guard (0...9).contains(tens) && (0...9).contains(ones) else {
throw BcryptError.invalidCost
}

return (version, tens * 10 + ones)
}
}
17 changes: 2 additions & 15 deletions Sources/Bcrypt/Verifier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,24 +33,11 @@ extension Bcrypt {
/// - Returns: `true` if the password matches the hash, `false` otherwise.
@inlinable
public static func verify(password: Span<UInt8>, against hash: Span<UInt8>) throws(BcryptError) -> Bool {
// $2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
// \__/\/ \____________________/\_____________________________/
// Alg Cost Salt Hash

guard hash.count == Bcrypt.hashSpace else {
throw BcryptError.invalidHash
}

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

let tens = Int(hash[4]) - 48
let ones = Int(hash[5]) - 48
guard (0...9).contains(tens) && (0...9).contains(ones) else {
throw BcryptError.invalidCost
}
let cost = tens * 10 + ones
let (version, cost) = try parseVersionAndCost(hash)

guard hash[6] == UInt8.separator else {
throw BcryptError.invalidHash
Expand All @@ -59,7 +46,7 @@ extension Bcrypt {
let key = password.count > Bcrypt.maxPasswordLength ? password.extracting(..<Bcrypt.maxPasswordLength) : password

let newHash = try InlineArray<60, UInt8> { outputSpan throws(BcryptError) in
try Bcrypt.hash(password: key, cost: cost, salt: hash.extracting(7..<29), version: version, into: &outputSpan)
try Bcrypt.hash(password: key, cost: cost, salt: hash.extracting(7..<Bcrypt.settingsSpace), version: version, into: &outputSpan)
}

return constantTimeEquals(newHash.span, hash)
Expand Down
62 changes: 62 additions & 0 deletions Tests/BcryptTests/BcryptTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,68 @@ struct BcryptTests {
)
}

@Test("Test Vectors via settings string", arguments: TestVector.all)
func testVectorsHashingWithSettings(testVector: TestVector) throws {
let settings = Array(testVector.expectedHash.utf8.prefix(29))
let hash = try Bcrypt.hash(password: Array(testVector.password.utf8).span, settings: settings.span)

#expect(
hash == Array(testVector.expectedHash.utf8),
"Expected: \(testVector.expectedHash), got: \(String(decoding: hash, as: UTF8.self))"
)
}

@Test("Settings string round trip")
func settingsRoundTrip() throws {
let hash = try Bcrypt.hash(password: "password", cost: 4, version: .v2y)
let settings = String(hash.prefix(29))

#expect(try Bcrypt.hash(password: "password", settings: settings) == hash)
#expect(try Bcrypt.hash(password: "wrong", settings: settings) != hash)
}

@Test("Settings string into OutputSpan")
func settingsIntoOutputSpan() throws {
let expected = "$2y$12$3cVe95jlT26PkfwkJ1Jl4uHBWGEFfdI3L95clNilo3p82rLvh6GwK"
let settings = Array(expected.utf8.prefix(29))
let hash = try InlineArray<60, UInt8> { output throws(BcryptError) in
try Bcrypt.hash(password: Array("test".utf8).span, settings: settings.span, into: &output)
}

var bytes: [UInt8] = []
for i in hash.indices {
bytes.append(hash[i])
}
#expect(bytes == Array(expected.utf8))
}

@Test("Malformed settings")
func malformedSettings() throws {
let salt = String(repeating: "A", count: 22)

for settings in ["$2a$10$", "$2a$10$" + salt + "A", "$2a$10$" + salt + salt + "AAAAAAA"] {
#expect(throws: BcryptError.invalidSettings) {
try Bcrypt.hash(password: "test", settings: settings)
}
}

#expect(throws: BcryptError.invalidSettings) {
try Bcrypt.hash(password: "test", settings: "$2a$10x" + salt)
}

#expect(throws: BcryptError.invalidVersion) {
try Bcrypt.hash(password: "test", settings: "$2z$10$" + salt)
}

#expect(throws: BcryptError.invalidCost) {
try Bcrypt.hash(password: "test", settings: "$2a$99$" + salt)
}

#expect(throws: BcryptError.invalidSalt) {
try Bcrypt.hash(password: "test", settings: "$2a$10$" + String(repeating: "!", count: 22))
}
}

@Test("End to end")
func endToEnd() throws {
let password = "password"
Expand Down
Loading