From ada0a0077c1b1f32ac12748a098dab6d78188556 Mon Sep 17 00:00:00 2001 From: Paul Toffoloni Date: Mon, 21 Sep 2026 13:16:52 +0200 Subject: [PATCH] Add `settings:` API as well to not only accept salts --- Sources/Bcrypt/Bcrypt.docc/Bcrypt.md | 12 ++++ Sources/Bcrypt/Bcrypt.swift | 3 +- Sources/Bcrypt/BcryptError.swift | 6 +- Sources/Bcrypt/Settings.swift | 89 ++++++++++++++++++++++++++++ Sources/Bcrypt/Verifier.swift | 17 +----- Tests/BcryptTests/BcryptTests.swift | 62 +++++++++++++++++++ 6 files changed, 172 insertions(+), 17 deletions(-) create mode 100644 Sources/Bcrypt/Settings.swift diff --git a/Sources/Bcrypt/Bcrypt.docc/Bcrypt.md b/Sources/Bcrypt/Bcrypt.docc/Bcrypt.md index f4977e9..bb2d9d8 100644 --- a/Sources/Bcrypt/Bcrypt.docc/Bcrypt.md +++ b/Sources/Bcrypt/Bcrypt.docc/Bcrypt.md @@ -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` 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 diff --git a/Sources/Bcrypt/Bcrypt.swift b/Sources/Bcrypt/Bcrypt.swift index 4b2be89..5dd50c8 100644 --- a/Sources/Bcrypt/Bcrypt.swift +++ b/Sources/Bcrypt/Bcrypt.swift @@ -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`; 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) diff --git a/Sources/Bcrypt/BcryptError.swift b/Sources/Bcrypt/BcryptError.swift index a008f78..1ea2b42 100644 --- a/Sources/Bcrypt/BcryptError.swift +++ b/Sources/Bcrypt/BcryptError.swift @@ -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. @@ -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 } diff --git a/Sources/Bcrypt/Settings.swift b/Sources/Bcrypt/Settings.swift new file mode 100644 index 0000000..c7b6dff --- /dev/null +++ b/Sources/Bcrypt/Settings.swift @@ -0,0 +1,89 @@ +// Ported from OpenBSD's bcrypt implementation (lib/libc/crypt/bcrypt.c). +// Copyright (c) 2014 Ted Unangst , Copyright (c) 1997 Niels Provos . +// 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, settings: Span) 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, settings: Span, into output: inout OutputSpan + ) 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..) 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) + } +} diff --git a/Sources/Bcrypt/Verifier.swift b/Sources/Bcrypt/Verifier.swift index 5dc85a7..0f72722 100644 --- a/Sources/Bcrypt/Verifier.swift +++ b/Sources/Bcrypt/Verifier.swift @@ -33,24 +33,11 @@ extension Bcrypt { /// - Returns: `true` if the password matches the hash, `false` otherwise. @inlinable public static func verify(password: Span, against hash: Span) 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 @@ -59,7 +46,7 @@ extension Bcrypt { let key = password.count > Bcrypt.maxPasswordLength ? password.extracting(.. { 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.. { 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"