diff --git a/.github/workflows/benchmark.yaml b/.github/workflows/benchmark.yaml
index 23db224..866f317 100644
--- a/.github/workflows/benchmark.yaml
+++ b/.github/workflows/benchmark.yaml
@@ -1,36 +1,64 @@
name: benchmark
+
concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.sha }}
+ group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
+ workflow_dispatch:
pull_request:
branches: [main]
+ paths:
+ - "**.swift"
+ - "Package.resolved"
+ - "Benchmarks/**"
+ - ".github/workflows/benchmark.yaml"
-env:
- RUN_LINK: "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
- CHECK_NAME: "benchmark / benchmark-vs-thresholds"
+permissions:
+ contents: read
+ pull-requests: write
jobs:
- benchmark-vs-thresholds:
- # https://runs-on.com/features/custom-runners/
- runs-on: ubuntu-latest
- container: swift:noble
-
- defaults:
- run:
- shell: bash --noprofile --norc -e -u -o pipefail {0}
+ benchmark-vs-main:
+ name: ${{ matrix.os }} ${{ matrix.configuration }}
+ runs-on: ${{ matrix.os }}
+ container: ${{ matrix.container }}
+ timeout-minutes: 45
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-26]
+ configuration: [release, debug]
+ include:
+ - os: ubuntu-latest
+ container: swiftlang/swift:nightly-6.4.x-noble
steps:
- # jemalloc is a dependency of the Benchmarking package
- # actions/cache will detect zstd and will become much faster.
- - name: Install jemalloc, zstd, curl and jq
+ - name: Install dependencies (Linux)
+ if: ${{ runner.os == 'Linux' }}
run: |
- apt update -q && apt install -yq libjemalloc-dev zstd curl jq
+ apt-get update -q
+ apt-get install -yq libjemalloc-dev jq curl zstd
- - name: Install node
+ - name: Install dependencies (macOS)
+ if: ${{ runner.os == 'macOS' }}
+ run: brew install jemalloc
+
+ - name: Install Swift (macOS)
+ if: ${{ runner.os == 'macOS' }}
run: |
- apt-get install -y nodejs
+ # Neither vapor/swiftly-action (Linux package managers only) nor swift-actions/setup-swift
+ # (hardcoded version table, no 6.4 yet) can install 6.4 on macOS, so drive swiftly directly:
+ # https://www.swift.org/install/macos/
+ curl -sSL https://download.swift.org/swiftly/darwin/swiftly.pkg -o "${RUNNER_TEMP}/swiftly.pkg"
+ installer -pkg "${RUNNER_TEMP}/swiftly.pkg" -target CurrentUserHomeDirectory
+ ~/.swiftly/bin/swiftly init --assume-yes --skip-install --no-modify-profile --quiet-shell-followup
+ . "${SWIFTLY_HOME_DIR:-${HOME}/.swiftly}/env.sh"
+ swiftly install --use --assume-yes 6.4
+ echo "SWIFTLY_HOME_DIR=${SWIFTLY_HOME_DIR}" >> "${GITHUB_ENV}"
+ echo "SWIFTLY_BIN_DIR=${SWIFTLY_BIN_DIR}" >> "${GITHUB_ENV}"
+ echo "${SWIFTLY_BIN_DIR}" >> "${GITHUB_PATH}"
+ swift --version
- name: Check out code
uses: actions/checkout@v6
@@ -38,50 +66,218 @@ jobs:
fetch-depth: 0
- name: Configure git
- run: git config --global --add safe.directory "${GITHUB_WORKSPACE}"
+ run: git config --global --add safe.directory '*'
- - name: Run benchmarks for PR
+ - name: Cache benchmark build
+ uses: actions/cache@v4
+ with:
+ path: Benchmarks/.build
+ key: benchmarks-${{ matrix.os }}-${{ matrix.configuration }}-${{ hashFiles('Benchmarks/Package.resolved') }}
+ restore-keys: benchmarks-${{ matrix.os }}-${{ matrix.configuration }}-
+
+ - name: Run benchmarks for the pull request
run: |
swift package -c release --disable-sandbox \
--package-path Benchmarks \
- benchmark baseline update pull-request --no-progress --quiet
+ benchmark baseline update pull_request --no-progress --quiet \
+ --benchmark-build-configuration ${{ matrix.configuration }}
- - name: Run benchmarks for main
- if: ${{ github.event_name == 'pull_request' }}
+ # The merge base is benchmarked with the pull request's Package.resolved so that both runs use
+ # the same package-benchmark plugin (older pins may lack flags used here). If the merge base
+ # cannot be benchmarked at all, the comparison is skipped rather than failing the job.
+ - name: Run benchmarks for the merge base
+ id: base
+ continue-on-error: true
run: |
+ cp Benchmarks/Package.resolved "${RUNNER_TEMP}/Package.resolved"
git stash
- git checkout main
+ git checkout --quiet ${{ github.event.pull_request.base.sha || 'main' }}
+ cp "${RUNNER_TEMP}/Package.resolved" Benchmarks/Package.resolved
swift package -c release --disable-sandbox \
--package-path Benchmarks \
- benchmark baseline update main --no-progress --quiet
+ benchmark baseline update main --no-progress --quiet \
+ --benchmark-build-configuration ${{ matrix.configuration }}
+
+ # The read/check/compare commands enumerate the benchmark targets of the checked-out package,
+ # and the merge base may have fewer targets than the pull request, so go back to the PR first.
+ # --force discards the Package.resolved we copied over the merge base; baselines are untracked.
+ - name: Restore the pull request checkout
+ run: git checkout --quiet --force ${{ github.sha }}
+
+ - name: Read pull request results
+ run: |
+ swift package -c release --disable-sandbox \
+ --package-path Benchmarks \
+ benchmark baseline read pull_request \
+ --format markdown --benchmark-build-configuration ${{ matrix.configuration }} \
+ | tee benchmark.md
- name: Compare benchmarks
id: comparison
+ if: ${{ steps.base.outcome == 'success' }}
run: |
- echo '## Summary' >> $GITHUB_STEP_SUMMARY
- echo $(date) >> $GITHUB_STEP_SUMMARY
- echo "exitStatus=1" >> $GITHUB_ENV
- swift package benchmark baseline check main pull_request --format markdown >> $GITHUB_STEP_SUMMARY
- echo '---' >> $GITHUB_STEP_SUMMARY
- swift package benchmark baseline compare main pull_request --no-progress --quiet --format markdown >> $GITHUB_STEP_SUMMARY
- echo "exitStatus=0" >> $GITHUB_ENV
- continue-on-error: true
+ set +e
+ swift package -c release --disable-sandbox \
+ --package-path Benchmarks \
+ benchmark baseline check main pull_request \
+ --format markdown --benchmark-build-configuration ${{ matrix.configuration }} \
+ | tee comparison.md
+ CHECK_EXIT=$?
+ set -e
+ echo "exit-status=${CHECK_EXIT}" >> "${GITHUB_OUTPUT}"
- - if: ${{ env.exitStatus == '0' }}
- name: Pull request comment text success
- id: prtestsuccess
+ printf '\n---\n\n' >> comparison.md
+ swift package -c release --disable-sandbox \
+ --package-path Benchmarks \
+ benchmark baseline compare main pull_request \
+ --no-progress --quiet --benchmark-build-configuration ${{ matrix.configuration }} \
+ --format markdown >> comparison.md
+
+ # Each job writes its full comparison to its own step summary and hands the short pieces to the
+ # report job below, which assembles a single PR comment for the whole matrix.
+ - name: Publish job results
+ env:
+ EXIT_STATUS: ${{ steps.comparison.outputs.exit-status }}
+ run: |
+ mkdir -p report
+ printf '%s' "${EXIT_STATUS}" > report/status.txt
+ if [ -f comparison.md ]; then
+ # The check verdict is the part before the '---' separator (the compare tables follow it),
+ # minus SwiftPM's build chatter and blank lines.
+ sed '/^---$/,$d' comparison.md | grep -vE '^(Building for|\[.*\]|Build complete!|\s*$)' > report/check.md || true
+ else
+ printf 'The merge base could not be benchmarked.\n' > report/check.md
+ fi
+ # Keep the per-target sections of the results, drop the baseline/host preamble above them.
+ awk '/^## / && !/^## Baseline/ { keep = 1 } keep' benchmark.md > report/results.md
+
+ {
+ echo "## Benchmark: ${{ matrix.os }}, ${{ matrix.configuration }}"
+ echo ""
+ if [ -f comparison.md ]; then
+ printf '\nComparison against main
\n\n%s\n\n \n\n' "$(cat comparison.md)"
+ fi
+ printf '\nPull request results
\n\n%s\n\n \n' "$(cat benchmark.md)"
+ } >> "${GITHUB_STEP_SUMMARY}"
+
+ - name: Upload job results
+ uses: actions/upload-artifact@v4
+ with:
+ name: benchmark-${{ matrix.os }}-${{ matrix.configuration }}
+ path: report
+ retention-days: 7
+
+ # Only regressions (2) fail the job; differences (1), improvements (4) and a skipped comparison pass.
+ - name: Fail on regressions
+ run: |
+ case '${{ steps.comparison.outputs.exit-status }}' in
+ ''|0|1|4) exit 0 ;;
+ *) exit 1 ;;
+ esac
+
+ # One comment for the whole matrix: a summary table, then a collapsible section per job. Runs even
+ # when a benchmark job failed so the comment reflects that. GITHUB_TOKEN is read-only for pull
+ # requests from forks, so the comment is only posted for same-repo PRs.
+ report:
+ name: Report
+ needs: benchmark-vs-main
+ if: ${{ always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Download job results
+ uses: actions/download-artifact@v4
+ with:
+ pattern: benchmark-*
+ path: results
+
+ - name: Construct the comment
+ env:
+ RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
- echo 'PRTEST<> $GITHUB_ENV
- echo "[Pull request benchmark comparison [${{ matrix.os }}] with 'main' run at $(date -Iseconds)](https://github.com/ordo-one/${{ github.event.repository.name }}/actions/runs/${{ github.run_id }})" >> $GITHUB_ENV
- echo 'EOF' >> $GITHUB_ENV
- - if: ${{ env.exitStatus == '1' }}
- name: Pull request comment text failure
- id: prtestfailure
+ describe() {
+ case "$1" in
+ 0) echo "✅ No significant differences" ;;
+ 1) echo "❓ Significant differences" ;;
+ 2) echo "❌ Regressions" ;;
+ 4) echo "⚠️ Improvements" ;;
+ '') echo "ℹ️ No baseline from main" ;;
+ missing) echo "🚫 Job failed" ;;
+ *) echo "🚫 Check exited with $1" ;;
+ esac
+ }
+
+ {
+ echo ''
+ echo "## Benchmark report"
+ echo ""
+ echo "Commit \`${HEAD_SHA:0:7}\` against \`main\`, cost 12. Full comparisons are in the [run summary](${RUN_URL})."
+ echo ""
+ echo "| Platform | Configuration | Result |"
+ echo "|:--|:--|:--|"
+ for os in ubuntu-latest macos-26; do
+ for configuration in release debug; do
+ dir="results/benchmark-${os}-${configuration}"
+ if [ -f "${dir}/status.txt" ]; then
+ status="$(cat "${dir}/status.txt")"
+ else
+ status=missing
+ fi
+ echo "| ${os} | ${configuration} | $(describe "${status}") |"
+ done
+ done
+ echo ""
+ for os in ubuntu-latest macos-26; do
+ for configuration in release debug; do
+ dir="results/benchmark-${os}-${configuration}"
+ [ -d "${dir}" ] || continue
+ echo ""
+ echo "${os}, ${configuration}
"
+ echo ""
+ cat "${dir}/check.md"
+ echo ""
+ cat "${dir}/results.md"
+ echo ""
+ echo " "
+ echo ""
+ done
+ done
+ } > comment.md
+
+ # GitHub rejects comment bodies over 65536 characters.
+ if [ "$(wc -c < comment.md)" -gt 60000 ]; then
+ head -c 60000 comment.md > comment.trimmed
+ printf '\n\n_Comment truncated; see the run summary for the rest._\n' >> comment.trimmed
+ mv comment.trimmed comment.md
+ fi
+
+ - name: Post or update the PR comment
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ API: ${{ github.api_url }}/repos/${{ github.repository }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
- echo 'PRTEST<> $GITHUB_ENV
- echo "[Pull request benchmark comparison [${{ matrix.os }}] with 'main' run at $(date -Iseconds)](https://github.com/ordo-one/${{ github.event.repository.name }}/actions/runs/${{ github.run_id }})" >> $GITHUB_ENV
- echo "_Pull request had performance regressions_" >> $GITHUB_ENV
- echo 'EOF' >> $GITHUB_ENV
+ BODY="$(jq -Rs . comment.md)"
+ EXISTING="$(curl -sS \
+ -H "Authorization: Bearer ${GH_TOKEN}" \
+ -H "Accept: application/vnd.github+json" \
+ "${API}/issues/${PR_NUMBER}/comments?per_page=100" \
+ | jq -r '[.[] | select(.body | contains(""))][0].id // empty')"
- - name: Exit with correct status
- run: exit ${{ steps.comparison.outputs.exit-status }}
+ if [ -n "${EXISTING}" ]; then
+ curl -sS -o /dev/null -w "update comment ${EXISTING}: %{http_code}\n" \
+ -X PATCH \
+ -H "Authorization: Bearer ${GH_TOKEN}" \
+ -H "Accept: application/vnd.github+json" \
+ "${API}/issues/comments/${EXISTING}" \
+ -d "{\"body\": ${BODY}}"
+ else
+ curl -sS -o /dev/null -w "create comment: %{http_code}\n" \
+ -X POST \
+ -H "Authorization: Bearer ${GH_TOKEN}" \
+ -H "Accept: application/vnd.github+json" \
+ "${API}/issues/${PR_NUMBER}/comments" \
+ -d "{\"body\": ${BODY}}"
+ fi
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b9d34a0..8f3333f 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,4 +1,9 @@
-name: Lint, Build & Test with Coverage
+name: test
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
on:
push:
branches: [main]
@@ -6,21 +11,50 @@ on:
branches: [main]
jobs:
- test:
- runs-on: ubuntu-latest
- container: swift:noble
+ unit-tests-linux:
+ name: Unit tests (Linux)
+ uses: swiftlang/github-workflows/.github/workflows/swift_package_test.yml@main
+ with:
+ linux_swift_versions: '["nightly-6.4.x"]'
+ linux_os_versions: '["noble"]'
+ enable_macos_checks: false
+ enable_windows_checks: false
+
+ unit-tests-macos:
+ name: Unit tests (macOS)
+ runs-on: macos-26
+ timeout-minutes: 30
steps:
- - name: Install curl
- run: apt-get update -yq && apt-get install -y curl
- - name: Checkout
- uses: actions/checkout@v4
- - name: Lint
- run: swift-format lint --recursive --strict --parallel .
+ - name: Install Swift
+ run: |
+ # Neither vapor/swiftly-action (Linux package managers only) nor swift-actions/setup-swift
+ # (hardcoded version table, no 6.4 yet) can install 6.4 on macOS, so drive swiftly directly:
+ # https://www.swift.org/install/macos/
+ curl -sSL https://download.swift.org/swiftly/darwin/swiftly.pkg -o "${RUNNER_TEMP}/swiftly.pkg"
+ installer -pkg "${RUNNER_TEMP}/swiftly.pkg" -target CurrentUserHomeDirectory
+ ~/.swiftly/bin/swiftly init --assume-yes --skip-install --no-modify-profile --quiet-shell-followup
+ . "${SWIFTLY_HOME_DIR:-${HOME}/.swiftly}/env.sh"
+ swiftly install --use --assume-yes 6.4
+ echo "SWIFTLY_HOME_DIR=${SWIFTLY_HOME_DIR}" >> "${GITHUB_ENV}"
+ echo "SWIFTLY_BIN_DIR=${SWIFTLY_BIN_DIR}" >> "${GITHUB_ENV}"
+ echo "${SWIFTLY_BIN_DIR}" >> "${GITHUB_PATH}"
+ swift --version
+ - name: Check out code
+ uses: actions/checkout@v6
- name: Build
run: swift build
- name: Test
- run: swift test --enable-code-coverage
- - name: Generate Coverage Report
- uses: vapor/swift-codecov-action@v0.3
- with:
- codecov_token: ${{ secrets.CODECOV_TOKEN }}
+ run: swift test
+
+ soundness:
+ name: Soundness
+ uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main
+ with:
+ format_check_enabled: true
+ broken_symlink_check_enabled: false
+ shell_check_enabled: false
+ yamllint_check_enabled: false
+ api_breakage_check_enabled: true
+ docs_check_enabled: false
+ license_header_check_enabled: false
+ unacceptable_language_check_enabled: false
diff --git a/Benchmarks/Hashing/Hashing.swift b/Benchmarks/Hashing/Hashing.swift
index 37c9544..7c927fe 100644
--- a/Benchmarks/Hashing/Hashing.swift
+++ b/Benchmarks/Hashing/Hashing.swift
@@ -2,11 +2,51 @@ import Bcrypt
import Benchmark
let benchmarks = { @Sendable in
+ #if os(Linux)
+ let metrics: [BenchmarkMetric] = [.mallocCountTotal, .wallClock]
+ #else
+ let metrics: [BenchmarkMetric] = [.mallocCountTotal, .instructions, .wallClock]
+ #endif
+
Benchmark.defaultConfiguration = .init(
- metrics: [.mallocCountTotal, .wallClock]
+ metrics: metrics,
+ thresholds: [
+ .mallocCountTotal: .init(absolute: BenchmarkThresholds.Absolute.strict),
+ .instructions: .init(relative: BenchmarkThresholds.Relative.default),
+ .wallClock: .init(relative: BenchmarkThresholds.Relative.none),
+ ]
)
- Benchmark("Hash 12") { benchmark in
- _ = try Bcrypt.hash(password: "password", cost: 12)
+ let cost = 12
+ let password = "password"
+ let passwordBytes = Array(password.utf8)
+ let salt = "R9h/cIPz0gi.URNNX3kh2O"
+ let saltBytes = Array(salt.utf8)
+
+ Benchmark("Hash 12 String") { benchmark in
+ blackHole(try Bcrypt.hash(password: password, cost: cost))
+ }
+
+ Benchmark("Hash 12 [UInt8]") { benchmark in
+ blackHole(try Bcrypt.hash(password: passwordBytes, cost: cost))
+ }
+
+ Benchmark("Hash 12 Span") { benchmark in
+ blackHole(try Bcrypt.hash(password: passwordBytes.span, cost: cost))
+ }
+
+ Benchmark("Hash 12 [UInt8] with salt") { benchmark in
+ blackHole(try Bcrypt.hash(password: passwordBytes, cost: cost, salt: saltBytes))
+ }
+
+ Benchmark("Hash 12 Span with salt") { benchmark in
+ blackHole(try Bcrypt.hash(password: passwordBytes.span, cost: cost, salt: saltBytes.span))
+ }
+
+ Benchmark("Hash 12 into OutputSpan") { benchmark in
+ let output = try InlineArray<60, UInt8>(initializingWith: { (output: inout OutputSpan) in
+ try Bcrypt.hash(password: passwordBytes.span, cost: cost, salt: saltBytes.span, into: &output)
+ })
+ blackHole(output)
}
}
diff --git a/Benchmarks/Package.resolved b/Benchmarks/Package.resolved
index 4c3c035..ff6272e 100644
--- a/Benchmarks/Package.resolved
+++ b/Benchmarks/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "5991d9ab29b68ca14be0d34f331578c38c933e5173d5a9a9d36a297818e9b701",
+ "originHash" : "c3f2961fc7ee59086b2a1e6ae9516a2be72334249f47e1401e5c880b09d42c18",
"pins" : [
{
"identity" : "hdrhistogram-swift",
@@ -15,8 +15,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/ordo-one/package-benchmark.git",
"state" : {
- "revision" : "acfd97b98f5a40d963c89437e1cfaeab8ef10bf9",
- "version" : "1.29.7"
+ "revision" : "fc19213de49fd7cbd28b41ff7c8aaad7524adcfb",
+ "version" : "1.29.9"
}
},
{
@@ -60,8 +60,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
- "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
- "version" : "1.6.3"
+ "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
+ "version" : "1.6.4"
}
},
{
diff --git a/Benchmarks/Package.swift b/Benchmarks/Package.swift
index c5769e6..0d772d6 100644
--- a/Benchmarks/Package.swift
+++ b/Benchmarks/Package.swift
@@ -1,14 +1,14 @@
-// swift-tools-version: 6.0
+// swift-tools-version: 6.4
import PackageDescription
let package = Package(
name: "benchmarks",
platforms: [
- .macOS(.v10_15)
+ .macOS(.v26)
],
dependencies: [
.package(path: "../"),
- .package(url: "https://github.com/ordo-one/package-benchmark.git", from: "1.29.0"),
+ .package(url: "https://github.com/ordo-one/package-benchmark.git", from: "1.29.9"),
],
targets: [
.executableTarget(
@@ -21,6 +21,17 @@ let package = Package(
plugins: [
.plugin(name: "BenchmarkPlugin", package: "package-benchmark")
]
- )
+ ),
+ .executableTarget(
+ name: "Verifying",
+ dependencies: [
+ .product(name: "Benchmark", package: "package-benchmark"),
+ .product(name: "Bcrypt", package: "bcrypt"),
+ ],
+ path: "Verifying",
+ plugins: [
+ .plugin(name: "BenchmarkPlugin", package: "package-benchmark")
+ ]
+ ),
]
)
diff --git a/Benchmarks/Verifying/Verifying.swift b/Benchmarks/Verifying/Verifying.swift
new file mode 100644
index 0000000..ad0f052
--- /dev/null
+++ b/Benchmarks/Verifying/Verifying.swift
@@ -0,0 +1,39 @@
+import Bcrypt
+import Benchmark
+
+let benchmarks = { @Sendable in
+ #if os(Linux)
+ let metrics: [BenchmarkMetric] = [.mallocCountTotal, .wallClock]
+ #else
+ let metrics: [BenchmarkMetric] = [.mallocCountTotal, .instructions, .wallClock]
+ #endif
+
+ Benchmark.defaultConfiguration = .init(
+ metrics: metrics,
+ thresholds: [
+ .mallocCountTotal: .init(absolute: BenchmarkThresholds.Absolute.strict),
+ .instructions: .init(relative: BenchmarkThresholds.Relative.default),
+ .wallClock: .init(relative: BenchmarkThresholds.Relative.none),
+ ]
+ )
+
+ let cost = 12
+ let password = "password"
+ let passwordBytes = Array(password.utf8)
+
+ // Hashed once up front so the benchmarks measure verification only.
+ let hashString = try! Bcrypt.hash(password: password, cost: cost)
+ let hashBytes = Array(hashString.utf8)
+
+ Benchmark("Verify 12 String") { benchmark in
+ blackHole(try Bcrypt.verify(password: password, against: hashString))
+ }
+
+ Benchmark("Verify 12 [UInt8]") { benchmark in
+ blackHole(try Bcrypt.verify(password: passwordBytes, against: hashBytes))
+ }
+
+ Benchmark("Verify 12 Span") { benchmark in
+ blackHole(try Bcrypt.verify(password: passwordBytes.span, against: hashBytes.span))
+ }
+}
diff --git a/Package.swift b/Package.swift
index 0ae64d8..ecdca38 100644
--- a/Package.swift
+++ b/Package.swift
@@ -1,15 +1,15 @@
-// swift-tools-version: 6.0
+// swift-tools-version: 6.4
import PackageDescription
let package = Package(
name: "bcrypt",
platforms: [
- .macOS(.v10_15)
+ .macOS(.v26)
],
products: [
.library(
name: "Bcrypt",
- targets: ["Bcrypt"]
+ targets: ["Bcrypt"],
)
],
targets: [
diff --git a/README.md b/README.md
index 0a54d6d..00646f5 100644
--- a/README.md
+++ b/README.md
@@ -24,11 +24,19 @@ let isValid = try Bcrypt.verify(password: password, hash: hash)
## Performance
-Currently these are the benchmarks for hashing the password "password" with cost factor 12, compared to Vapor's C Bcrypt implementation. Measurements were taken on an M2 MacBook Air.
+Benchmarks for hashing the password "password" at cost factor 12 using the
+```swift
+func hash(
+ password: Span,
+ cost: Int = 10,
+ salt: Span,
+ version: BcryptVersion = .v2b,
+ into output: inout OutputSpan
+) throws(BcryptError)
+```
+API, compared to Vapor's C bcrypt ([vapor/authentication](https://github.com/vapor/authentication)), measured on an Apple M5 Pro with the [benchmark](https://github.com/ordo-one/benchmark) package.
| | Release ms | Debug ms | Allocations Release | Allocations Debug |
|------|------------|----------|---------------------|-------------------|
-| vapor/authentication | 215ms | 337ms | ~13,700 | ~13,800 |
-| bcrypt | 195ms | 453ms | ~13,400 | ~13,500 |
-
-
+| vapor/authentication | 170ms | 270ms | 11 | 181 |
+| bcrypt | 149ms | 440ms | 0 | 0 |
diff --git a/Sources/Bcrypt/Base64.swift b/Sources/Bcrypt/Base64.swift
index 05c7c49..99b4ee2 100644
--- a/Sources/Bcrypt/Base64.swift
+++ b/Sources/Bcrypt/Base64.swift
@@ -1,5 +1,7 @@
+@usableFromInline
enum Base64Error: Error {
case invalidCharacter
+ case invalidLength
}
@usableFromInline
@@ -31,46 +33,42 @@ struct Base64 {
]
@usableFromInline
- static func encode(_ bytes: [UInt8], count: Int) -> [UInt8] {
- guard bytes.count > 0 || count > 0 else {
- return []
- }
+ static func encode(_ bytes: Span, count: Int, into output: inout OutputSpan) {
+ guard bytes.count > 0 || count > 0 else { return }
let len = min(bytes.count, count)
var offset: Int = 0
var c1: UInt8
var c2: UInt8
- var result: [UInt8] = []
while offset < len {
c1 = bytes[offset] & 0xff
offset &+= 1
- result.append(encodingTable[Int(truncatingIfNeeded: (c1 &>> 2) & 0x3f)])
+ output.append(encodingTable[Int(truncatingIfNeeded: (c1 &>> 2) & 0x3f)])
+
c1 = (c1 & 0x03) &<< 4
if offset >= len {
- result.append(encodingTable[Int(truncatingIfNeeded: c1 & 0x3f)])
+ output.append(encodingTable[Int(truncatingIfNeeded: c1 & 0x3f)])
break
}
c2 = bytes[offset] & 0xff
offset &+= 1
c1 |= (c2 &>> 4) & 0x0f
- result.append(encodingTable[Int(truncatingIfNeeded: c1 & 0x3f)])
+ output.append(encodingTable[Int(truncatingIfNeeded: c1 & 0x3f)])
c1 = (c2 & 0x0f) &<< 2
if offset >= len {
- result.append(encodingTable[Int(truncatingIfNeeded: c1 & 0x3f)])
+ output.append(encodingTable[Int(truncatingIfNeeded: c1 & 0x3f)])
break
}
c2 = bytes[offset] & 0xff
offset &+= 1
c1 |= (c2 &>> 6) & 0x03
- result.append(encodingTable[Int(truncatingIfNeeded: c1 & 0x3f)])
- result.append(encodingTable[Int(truncatingIfNeeded: c2 & 0x3f)])
+ output.append(encodingTable[Int(truncatingIfNeeded: c1 & 0x3f)])
+ output.append(encodingTable[Int(truncatingIfNeeded: c2 & 0x3f)])
}
-
- return result
}
private static func char64(of x: UInt8) -> UInt8 {
@@ -78,10 +76,9 @@ struct Base64 {
}
@usableFromInline
- static func decode(_ s: [UInt8], count maxolen: Int) throws(Base64Error) -> [UInt8] {
+ static func decode(_ s: Span, count maxolen: Int, into output: inout OutputSpan) throws(Base64Error) {
var off = 0
var olen = 0
- var result = [UInt8](repeating: 0, count: maxolen)
var c1: UInt8
var c2: UInt8
@@ -100,7 +97,7 @@ struct Base64 {
o = c1 &<< 2
o |= (c2 & 0x30) &>> 4
- result[olen] = o
+ output.append(o)
olen &+= 1
if olen >= maxolen || off >= s.count {
break
@@ -115,7 +112,7 @@ struct Base64 {
o = (c2 & 0x0f) &<< 4
o |= (c3 & 0x3c) &>> 2
- result[olen] = o
+ output.append(o)
olen &+= 1
if olen >= maxolen || off >= s.count {
break
@@ -125,10 +122,9 @@ struct Base64 {
off &+= 1
o = (c3 & 0x03) &<< 6
o |= c4
- result[olen] = o
+ output.append(o)
olen &+= 1
}
- return Array(result[0.. {
[.separator, majorVersion, minorVersion, .separator] // $2x$
}
@usableFromInline
- init?(identifier: ArraySlice) {
- switch identifier {
- case [0x24, 0x32, 0x61, 0x24]: self = .v2a
- case [0x24, 0x32, 0x62, 0x24], [0x24, 0x32, 0x79, 0x24]: self = .v2b // use 2y as alias for 2b
+ init?(identifier: Span) {
+ guard identifier.count == 4 else { return nil }
+ switch (identifier[0], identifier[1], identifier[2], identifier[3]) {
+ case (0x24, 0x32, 0x61, 0x24): self = .v2a
+ case (0x24, 0x32, 0x62, 0x24), (0x24, 0x32, 0x79, 0x24): self = .v2b // 2y is an alias for 2b
default: return nil
}
}
diff --git a/Sources/Bcrypt/Blowfish+InitialState.swift b/Sources/Bcrypt/Blowfish+InitialState.swift
index 9d90376..6bade41 100644
--- a/Sources/Bcrypt/Blowfish+InitialState.swift
+++ b/Sources/Bcrypt/Blowfish+InitialState.swift
@@ -1,7 +1,7 @@
extension EksBlowfish {
@usableFromInline
@inline(__always)
- static let initialP: [UInt32] = [
+ static let initialP: InlineArray<18, UInt32> = [
0x243f_6a88, 0x85a3_08d3, 0x1319_8a2e, 0x0370_7344,
0xa409_3822, 0x299f_31d0, 0x082e_fa98, 0xec4e_6c89,
0x4528_21e6, 0x38d0_1377, 0xbe54_66cf, 0x34e9_0c6c,
@@ -11,7 +11,7 @@ extension EksBlowfish {
@usableFromInline
@inline(__always)
- static let initialS: [UInt32] = [
+ static let initialS: InlineArray<1024, UInt32> = [
0xd131_0ba6, 0x98df_b5ac, 0x2ffd_72db, 0xd01a_dfb7,
0xb8e1_afed, 0x6a26_7e96, 0xba7c_9045, 0xf12c_7f99,
0x24a1_9947, 0xb391_6cf7, 0x0801_f2e2, 0x858e_fc16,
diff --git a/Sources/Bcrypt/EksBlowfish.swift b/Sources/Bcrypt/EksBlowfish.swift
index cb1d8de..51e528c 100644
--- a/Sources/Bcrypt/EksBlowfish.swift
+++ b/Sources/Bcrypt/EksBlowfish.swift
@@ -3,44 +3,49 @@
/// This work is based on
/// 1. Applied Cryptography, Second Edition by Bruce Schneier, section 14 and the corresponding code in Part V.
/// 2. The OpenBSD implementation of bcrypt at https://github.com/openbsd/src/blob/master/lib/libc/crypt/bcrypt.c.
-/// The function names and variable names are kept the same as in the OpenBSD implementation.
+/// The function names and variable names are kept the same as in the OpenBSD implementation.
@usableFromInline enum EksBlowfish {
@usableFromInline static let N = 16 // Cipher Rounds
@usableFromInline
- static func setup(password: [UInt8], salt: [UInt8], cost: Int) -> (p: [UInt32], s: [UInt32]) {
+ static func setup(password: Span, salt: Span, cost: Int) -> (p: InlineArray<18, UInt32>, s: InlineArray<1024, UInt32>) {
precondition(cost >= 4 && cost <= 31, "Cost must be between 4 and 31, is \(cost)")
precondition(salt.count == 16, "Salt must be 16 bytes long, is \(salt.count)")
- precondition(
- password.count > 0 && password.count <= 73,
- "Password must be between 1 and 73 bytes long counting the NULL terminator, is \(password.count)")
+ // No length limit on the password here: the key stream only reads 72 bytes (18 words) into P,
+ // so longer keys are implicitly truncated. The `2b` limit is enforced by the caller.
var (p, s) = (Self.initialP, Self.initialS)
- expandState(password: password, salt: salt, p: &p, s: &s)
+ var pSpan = p.mutableSpan
+ var sSpan = s.mutableSpan
+
+ expandState(password: password, salt: salt, p: &pSpan, s: &sSpan)
var i = 1 &<< cost
while i > 0 {
- expand0State(key: password, p: &p, s: &s)
- expand0State(key: salt, p: &p, s: &s)
+ expand0State(key: password, nulTerminated: true, p: &pSpan, s: &sSpan)
+ expand0State(key: salt, nulTerminated: false, p: &pSpan, s: &sSpan)
i &-= 1
}
return (p, s)
}
+ /// Reads the next big-endian word from `data`, wrapping around to the start when the end is reached.
+ ///
+ /// With `nulTerminated`, a virtual NUL byte is streamed after the last byte of `data`, matching the C
+ /// implementations that feed `strlen(key) + 1` bytes of the password into the key schedule, without
+ /// having to copy the password into a terminated buffer.
@usableFromInline
- static func stream2word(data: [UInt8], j: inout Int) -> UInt32 {
+ static func stream2word(data: Span, nulTerminated: Bool, j: inout Int) -> UInt32 {
+ let length = nulTerminated ? data.count &+ 1 : data.count
var word: UInt32 = 0
- var i = 0
- while i < 4 {
- if j >= data.count {
- j = 0
- }
- word = (word &<< 8) | UInt32(truncatingIfNeeded: data[j])
- i &+= 1
+ for _ in 0..<4 {
+ if j >= length { j = 0 }
+ let byte: UInt8 = j < data.count ? data[unchecked: j] : 0
+ word = (word &<< 8) | UInt32(byte)
j &+= 1
}
@@ -48,11 +53,16 @@
}
@usableFromInline
- static func expand0State(key: [UInt8], p: inout [UInt32], s: inout [UInt32]) {
+ static func expand0State(
+ key: Span,
+ nulTerminated: Bool,
+ p: inout MutableSpan,
+ s: inout MutableSpan
+ ) {
var j = 0
var i = 0
while i < Self.N &+ 2 {
- p[i] ^= stream2word(data: key, j: &j)
+ p[i] ^= stream2word(data: key, nulTerminated: nulTerminated, j: &j)
i &+= 1
}
@@ -85,15 +95,15 @@
@usableFromInline
static func expandState(
- password: [UInt8],
- salt: [UInt8],
- p: inout [UInt32],
- s: inout [UInt32]
+ password: Span,
+ salt: Span,
+ p: inout MutableSpan,
+ s: inout MutableSpan
) {
var j = 0
var i = 0
while i < Self.N &+ 2 {
- p[i] ^= stream2word(data: password, j: &j)
+ p[i] ^= stream2word(data: password, nulTerminated: true, j: &j)
i &+= 1
}
@@ -103,8 +113,8 @@
var dataR: UInt32 = 0
while i < Self.N &+ 2 {
- dataL ^= stream2word(data: salt, j: &j)
- dataR ^= stream2word(data: salt, j: &j)
+ dataL ^= stream2word(data: salt, nulTerminated: false, j: &j)
+ dataR ^= stream2word(data: salt, nulTerminated: false, j: &j)
encipher(xl: &dataL, xr: &dataR, p: p, s: s)
p[i] = dataL
@@ -116,8 +126,8 @@
while i < 4 {
var k = 0
while k < 256 {
- dataL ^= stream2word(data: salt, j: &j)
- dataR ^= stream2word(data: salt, j: &j)
+ dataL ^= stream2word(data: salt, nulTerminated: false, j: &j)
+ dataR ^= stream2word(data: salt, nulTerminated: false, j: &j)
encipher(xl: &dataL, xr: &dataR, p: p, s: s)
s[i &* 0x100 &+ k] = dataL
@@ -128,34 +138,38 @@
}
}
+ @_transparent
@usableFromInline
- @inline(__always)
- static func encipher(xl: inout UInt32, xr: inout UInt32, p: UnsafePointer, s: UnsafePointer) {
- var Xl = xl
- var Xr = xr
-
- Xl ^= p[0]
-
- var i = 1
- while i <= 16 {
- // F(Xr)
- let a1 = s[Int(truncatingIfNeeded: (Xl &>> 24) & 0xff)]
- let b1 = s[0x100 &+ Int(truncatingIfNeeded: (Xl &>> 16) & 0xff)]
- let c1 = s[0x200 &+ Int(truncatingIfNeeded: (Xl &>> 8) & 0xff)]
- let d1 = s[0x300 &+ Int(truncatingIfNeeded: Xl & 0xff)]
- Xr ^= ((a1 &+ b1) ^ c1 &+ d1) ^ p[i]
-
- // F(Xl)
- let a2 = s[Int(truncatingIfNeeded: (Xr &>> 24) & 0xff)]
- let b2 = s[0x100 &+ Int(truncatingIfNeeded: (Xr &>> 16) & 0xff)]
- let c2 = s[0x200 &+ Int(truncatingIfNeeded: (Xr &>> 8) & 0xff)]
- let d2 = s[0x300 &+ Int(truncatingIfNeeded: Xr & 0xff)]
- Xl ^= ((a2 &+ b2) ^ c2 &+ d2) ^ p[i &+ 1]
-
- i &+= 2
- }
+ static func f(_ s: borrowing MutableSpan, _ x: UInt32) -> UInt32 {
+ let a = s[unchecked: Int(truncatingIfNeeded: x &>> 24)]
+ let b = s[unchecked: 0x100 &+ Int(truncatingIfNeeded: (x &>> 16) & 0xff)]
+ let c = s[unchecked: 0x200 &+ Int(truncatingIfNeeded: (x &>> 8) & 0xff)]
+ let d = s[unchecked: 0x300 &+ Int(truncatingIfNeeded: x & 0xff)]
+ return ((a &+ b) ^ c) &+ d
+ }
- xl = Xr ^ p[17]
- xr = Xl
+ @_transparent
+ @usableFromInline
+ static func encipher(xl: inout UInt32, xr: inout UInt32, p: borrowing MutableSpan, s: borrowing MutableSpan) {
+ var l = xl ^ p[unchecked: 0]
+ var r = xr
+ r ^= f(s, l) ^ p[unchecked: 1]
+ l ^= f(s, r) ^ p[unchecked: 2]
+ r ^= f(s, l) ^ p[unchecked: 3]
+ l ^= f(s, r) ^ p[unchecked: 4]
+ r ^= f(s, l) ^ p[unchecked: 5]
+ l ^= f(s, r) ^ p[unchecked: 6]
+ r ^= f(s, l) ^ p[unchecked: 7]
+ l ^= f(s, r) ^ p[unchecked: 8]
+ r ^= f(s, l) ^ p[unchecked: 9]
+ l ^= f(s, r) ^ p[unchecked: 10]
+ r ^= f(s, l) ^ p[unchecked: 11]
+ l ^= f(s, r) ^ p[unchecked: 12]
+ r ^= f(s, l) ^ p[unchecked: 13]
+ l ^= f(s, r) ^ p[unchecked: 14]
+ r ^= f(s, l) ^ p[unchecked: 15]
+ l ^= f(s, r) ^ p[unchecked: 16]
+ xl = r ^ p[unchecked: 17]
+ xr = l
}
}
diff --git a/Sources/Bcrypt/Hasher.swift b/Sources/Bcrypt/Hasher.swift
index 6f0d586..526dc79 100644
--- a/Sources/Bcrypt/Hasher.swift
+++ b/Sources/Bcrypt/Hasher.swift
@@ -1,5 +1,9 @@
extension Bcrypt {
- @usableFromInline static let cipherText = Array("OrpheanBeholderScryDoubt".utf8)
+ /// "OrpheanBeholderScryDoubt" as six big-endian words; the block that bcrypt encrypts 64 times with the derived key.
+ @usableFromInline static let cipherText: InlineArray<6, UInt32> = [
+ 0x4f72_7068, 0x6561_6e42, 0x6568_6f6c,
+ 0x6465_7253, 0x6372_7944, 0x6f75_6274,
+ ]
@usableFromInline static let maxSalt = 16
@usableFromInline static let saltSpace = 22
@usableFromInline static let words = 6
@@ -14,10 +18,7 @@ extension Bcrypt {
/// - Returns: the hashed password.
@inlinable
public static func hash(password: String, cost: Int = 10, version: BcryptVersion = .v2b) throws(BcryptError) -> String {
- String(
- decoding: try hash(password: Array(password.utf8), cost: cost, salt: Self.generateRandomSalt(), version: version),
- as: UTF8.self
- )
+ String(decoding: try hash(password: password.utf8Span.span, cost: cost, version: version), as: UTF8.self)
}
/// Hashes a password using the bcrypt algorithm.
@@ -29,14 +30,27 @@ extension Bcrypt {
/// - Returns: the hashed password.
@inlinable
public static func hash(password: [UInt8], cost: Int = 10, version: BcryptVersion = .v2b) throws(BcryptError) -> [UInt8] {
- try hash(password: password, cost: cost, salt: Self.generateRandomSalt(), version: version)
+ try hash(password: password.span, cost: cost, version: version)
}
/// Hashes a password using the bcrypt algorithm.
/// - Parameters:
/// - password: the password to hash.
/// - cost: number of rounds to apply the key derivation function, used as log2(cost). Must be between 4 and 31.
- /// - salt: the salt to use for the hash.
+ /// - version: the version of the bcrypt algorithm to use. Defaults to `v2b`.
+ /// - Throws: ``BcryptError``
+ /// - Returns: the hashed password.
+ @inlinable
+ public static func hash(password: Span, cost: Int = 10, version: BcryptVersion = .v2b) throws(BcryptError) -> [UInt8] {
+ let salt = Self.generateRandomSalt()
+ return try hash(password: password, cost: cost, salt: salt.span, version: version)
+ }
+
+ /// Hashes a password using the bcrypt algorithm.
+ /// - Parameters:
+ /// - password: the password to hash.
+ /// - cost: number of rounds to apply the key derivation function, used as log2(cost). Must be between 4 and 31.
+ /// - salt: the 22 character, base64 encoded salt to use for the hash.
/// - version: the version of the bcrypt algorithm to use. Defaults to `v2b`.
/// - Throws: ``BcryptError``
/// - Returns: the hashed password.
@@ -44,36 +58,69 @@ extension Bcrypt {
public static func hash(
password: [UInt8], cost: Int = 10, salt: [UInt8], version: BcryptVersion = .v2b
) throws(BcryptError) -> [UInt8] {
- guard (salt.count * 3 / 4) - 1 < Self.maxSalt else {
+ try hash(password: password.span, cost: cost, salt: salt.span, version: version)
+ }
+
+ /// Hashes a password using the bcrypt algorithm.
+ /// - Parameters:
+ /// - password: the password to hash.
+ /// - cost: number of rounds to apply the key derivation function, used as log2(cost). Must be between 4 and 31.
+ /// - salt: the 22 character, base64 encoded salt to use for the hash.
+ /// - version: the version of the bcrypt algorithm to use. Defaults to `v2b`.
+ /// - Throws: ``BcryptError``
+ /// - Returns: the hashed password.
+ @inlinable
+ public static func hash(
+ password: Span, cost: Int = 10, salt: Span, version: BcryptVersion = .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)
+ }
+ }
+
+ /// Hashes a password using the bcrypt algorithm, writing the 60 byte hash into `output`.
+ /// - Parameters:
+ /// - password: the password to hash.
+ /// - cost: number of rounds to apply the key derivation function, used as log2(cost). Must be between 4 and 31.
+ /// - salt: the 22 character, base64 encoded salt to use for the hash.
+ /// - version: the version of the bcrypt algorithm to use. Defaults to `v2b`.
+ /// - output: receives the 60 bytes of the hash. Must have room for at least 60 more elements.
+ /// - Throws: ``BcryptError``
+ public static func hash(
+ password: Span,
+ cost: Int = 10,
+ salt: Span,
+ version: BcryptVersion = .v2b,
+ into output: inout OutputSpan
+ ) throws(BcryptError) {
+ guard salt.count == Self.saltSpace else {
throw BcryptError.invalidSaltLength
}
- let cSalt: [UInt8]
+ let cSalt: InlineArray<16, UInt8>
do {
- cSalt = try Base64.decode(salt, count: Self.maxSalt)
+ cSalt = try InlineArray<16, UInt8>(initializingWith: { (output: inout OutputSpan) throws(Base64Error) in
+ try Base64.decode(salt, count: Self.maxSalt, into: &output)
+ guard output.count == Self.maxSalt else {
+ throw .invalidLength
+ }
+ })
} catch {
throw BcryptError.invalidSalt
}
- guard cSalt.count == 16 else {
- throw BcryptError.invalidSalt
- }
-
guard password.count > 0 else {
throw BcryptError.emptyPassword
}
- let password =
- if password[password.endIndex &- 1] == 0 {
- Array(password[password.startIndex..> 8) & 0xff)
- cipherText[4 &* i &+ 1] = UInt8((cData[i] &>> 16) & 0xff)
- cipherText[4 &* i &+ 0] = UInt8((cData[i] &>> 24) & 0xff)
- i &+= 1
+ // Big-endian bytes of cData; only the first 23 are part of the hash.
+ let cipherBytes = InlineArray<24, UInt8> { i in
+ UInt8(truncatingIfNeeded: cData[i / 4] &>> (24 &- 8 &* (i % 4)))
}
- var output = [UInt8]()
+ for index in version.identifier.indices {
+ output.append(version.identifier[index])
+ }
- let cost: [UInt8] =
- switch cost {
- case 0...9:
- [0x30, UInt8(cost + 0x30)]
- default:
- [UInt8(cost / 10 + 0x30), UInt8(cost % 10 + 0x30)]
- }
+ switch cost {
+ case 0...9:
+ output.append(0x30)
+ output.append(UInt8(cost &+ 0x30))
+ default:
+ output.append(UInt8(cost / 10 + 0x30))
+ output.append(UInt8(cost % 10 + 0x30))
+ }
- let prefix = version.identifier + cost + [36]
+ output.append(36)
- output += prefix
- output += salt
- output += Base64.encode(cipherText, count: 4 * Self.words - 1)
+ for index in salt.indices {
+ output.append(salt[index])
+ }
- return output
+ Base64.encode(cipherBytes.span, count: 4 * Self.words - 1, into: &output)
}
// $2a$12$R9h/cIPz0gi.URNNX3kh2OPST9/PgBkqquzi.Ss7KIUgO2t0jWMUW
// \__/\/ \____________________/\_____________________________/
// Alg Cost Salt Hash
@usableFromInline
- static func generateRandomSalt() -> [UInt8] {
- var salt = [UInt8](repeating: 0, count: saltSpace)
-
- var cSalt = [UInt8](repeating: 0, count: maxSalt)
- var i = 0
- while i < maxSalt {
- cSalt[i] = UInt8.random(in: .min ... .max)
- i &+= 1
- }
-
- let encodedSalt = Base64.encode(cSalt, count: Self.hashSpace)
- i = 0
- while i < encodedSalt.count {
- if i < saltSpace {
- salt[i] = encodedSalt[i]
- }
- i &+= 1
+ static func generateRandomSalt() -> InlineArray<22, UInt8> {
+ let cSalt = InlineArray<16, UInt8> {
+ _ in UInt8.random(in: .min ... .max)
}
-
- return salt
+ return InlineArray<22, UInt8>(initializingWith: { outputSpan in
+ Base64.encode(cSalt.span, count: Self.hashSpace, into: &outputSpan)
+ })
}
}
diff --git a/Sources/Bcrypt/Verifier.swift b/Sources/Bcrypt/Verifier.swift
index 6081a1d..fc2f64e 100644
--- a/Sources/Bcrypt/Verifier.swift
+++ b/Sources/Bcrypt/Verifier.swift
@@ -7,7 +7,7 @@ extension Bcrypt {
/// - Returns: `true` if the password matches the hash, `false` otherwise.
@inlinable
public static func verify(password: String, against hash: String) throws(BcryptError) -> Bool {
- try verify(password: Array(password.utf8), against: Array(hash.utf8))
+ try verify(password: password.utf8Span.span, against: hash.utf8Span.span)
}
/// Verifies a password against a hash.
@@ -18,6 +18,17 @@ extension Bcrypt {
/// - Returns: `true` if the password matches the hash, `false` otherwise.
@inlinable
public static func verify(password: [UInt8], against hash: [UInt8]) throws(BcryptError) -> Bool {
+ try verify(password: password.span, against: hash.span)
+ }
+
+ /// Verifies a password against a hash.
+ /// - Parameters:
+ /// - password: the password to verify.
+ /// - hash: the hash to verify against.
+ /// - Throws: ``BcryptError``
+ /// - 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
@@ -26,7 +37,7 @@ extension Bcrypt {
throw BcryptError.invalidHash
}
- guard let version = BcryptVersion(identifier: hash[0...3]) else {
+ guard let version = BcryptVersion(identifier: hash.extracting(0...3)) else {
throw BcryptError.invalidVersion
}
@@ -37,15 +48,15 @@ extension Bcrypt {
}
let cost = tens * 10 + ones
- let salt = Array(hash[7...28])
-
- let newHash = try Bcrypt.hash(password: password, cost: cost, salt: salt, version: version)
+ let newHash = try InlineArray<60, UInt8> { outputSpan throws(BcryptError) in
+ try Bcrypt.hash(password: password, cost: cost, salt: hash.extracting(7..<29), version: version, into: &outputSpan)
+ }
- return constantTimeEquals(newHash, hash)
+ return constantTimeEquals(newHash.span, hash)
}
}
-@usableFromInline func constantTimeEquals(_ a: [UInt8], _ b: [UInt8]) -> Bool {
+@usableFromInline func constantTimeEquals(_ a: Span, _ b: Span) -> Bool {
guard a.count == b.count else { return false }
var areEqual: UInt8 = 0
var i = a.count - 1
diff --git a/Sources/Benchmarks/Hashing/Hashing.swift b/Sources/Benchmarks/Hashing/Hashing.swift
deleted file mode 100644
index b5d8996..0000000
--- a/Sources/Benchmarks/Hashing/Hashing.swift
+++ /dev/null
@@ -1,8 +0,0 @@
-import Bcrypt
-import Benchmark
-
-let benchmarks = {
- Benchmark.defaultConfiguration = .init(
- metrics: []
- )
-}