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: [] - ) -}