Skip to content

Add pure Ruby fallback for bcrypt_pbkdf (enables JRuby) - #39

Open
mfazekas wants to merge 1 commit into
mainfrom
pure-ruby-fallback
Open

mfazekas wants to merge 1 commit into
mainfrom
pure-ruby-fallback

Conversation

@mfazekas

Copy link
Copy Markdown
Contributor

When the C extension is unavailable (JRuby, or any environment without a native compiler), load a pure Ruby implementation instead of failing with a LoadError.

The pure Ruby path is ~68x slower than the C extension at rounds=4 (~930ms vs ~14ms), but bcrypt_pbkdf is only used in net-ssh for decrypting encrypted Ed25519 private keys — a one-time cost at connection time — so this is acceptable.

JRuby users currently hit a hardcoded raise "BCryptPbkdf is not implemented for jruby" in net-ssh; with this change that can be removed.

All existing test vectors pass under the pure Ruby implementation.

When the C extension is unavailable (JRuby or environments without a
native compiler), load a pure Ruby implementation of Blowfish/bcrypt_pbkdf
instead of raising. Approximately 68x slower than the C extension but
acceptable for the one-time key-decryption use case in net-ssh.
@mfazekas

Copy link
Copy Markdown
Contributor Author

Benchmark

Tested on Apple M1 (arm64-darwin), Ruby 2.7.8 / JRuby 9.4.9.0 (OpenJDK 20).

                               ms/call
Case (keylen/rounds)    C ext   Java ext   Pure Ruby   Pure Ruby
                        (MRI)   (JRuby)    (JRuby)     (MRI)
--------------------------------------------------------------
12  / 2                  7.3      11ms       410ms       466ms
32  / 4                 13.8      19ms       604ms       938ms
64  / 8                 60.1      77ms      2350ms      3935ms
16  / 42               150.0     197ms      6044ms     10085ms
32  / 16 (net-ssh default) 56.2  77ms      2389ms      4644ms

The pure Ruby fallback is ~30x slower than the Java extension and ~68x slower than the C extension. At rounds=16 (the openssh default for encrypted Ed25519 keys) that's ~2.4s on JRuby — noticeable but unblocking compared to the current hard raise.

This PR is intended as a stopgap so JRuby users aren't completely blocked. The right fix for JRuby performance is #30 (Java extension).

@headius

headius commented Sep 29, 2026

Copy link
Copy Markdown

I also looked into the perf of the extension, and there's a lot that can be improved. It copies incoming and outgoing values at least twice, uses Java integration for calls (copying the input again). I'm working on some fixes, but the first commit should probably be to clean up the tab characters that snuck in with the initial PR.

@headius

headius commented Sep 30, 2026

Copy link
Copy Markdown

@mfazekas Is that benchmark published somewhere? I would like to do more profiling but I think I had the wrong code in hand before.

Glancing through the correct core submitted by @kares I can definitely see opportunities (as mentioned above, reducing copying and transient object allocations would probably have a big impact).

@mfazekas

mfazekas commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor Author

Tested on Apple M2 Max, MRI 2.7.8 / JRuby 9.4.9.0 (OpenJDK 20).

                                  ms/call
Case (keylen/rounds)    C ext   Java ext   Pure Ruby   Pure Ruby
                        (MRI)   (JRuby)    (JRuby)     (MRI)
-----------------------------------------------------------------
pass2   / 12 / 2          6.2       8.7       323ms       430ms
password/ 32 / 4         12.4      17.3       585ms       846ms
password/ 64 / 8         49.8      67.6      2334ms      3488ms
password/ 16 / 42       131.2     177.5      6004ms      8930ms
password/ 32 / 16        50.0      67.6      2305ms      3431ms
  (net-ssh default)

Java ext is ~1.35× slower than C ext. Pure Ruby is ~34–38× slower than Java ext on JRuby.

bench/pure_ruby_bench.rb
require 'benchmark'

$LOAD_PATH.unshift File.expand_path('../lib', __dir__)

native = ENV['NATIVE'] != '0'
if native
  require 'bcrypt_pbkdf'
  label = 'C/Java ext'
else
  require 'bcrypt_pbkdf/pure_ruby'
  label = 'Pure Ruby  '
end

vectors = [
  ["pass2",    "salt2",    12,  2],
  ["password", "salt",     32,  4],
  ["password", "salt",     64,  8],
  ["password", "salt",     16, 42],
  ["password", "salt",     32, 16],
]

WARMUP = native ? 5 : 1
N      = native ? 20 : 2

puts "#{label} — #{RUBY_DESCRIPTION}"
puts "%-30s %8s" % ["input (keylen/rounds)", "ms/call"]
puts "-" * 42
vectors.each do |pass, salt, keylen, rounds|
  WARMUP.times { BCryptPbkdf::Engine.__bc_crypt_pbkdf(pass, salt, keylen, rounds) }
  t = Benchmark.realtime { N.times { BCryptPbkdf::Engine.__bc_crypt_pbkdf(pass, salt, keylen, rounds) } }
  puts "%-30s %8.1f" % ["#{pass[0,8]}/#{keylen}/#{rounds}", t / N * 1000]
end
# MRI — C ext
bundle exec ruby bench/pure_ruby_bench.rb

# MRI — pure Ruby
NATIVE=0 bundle exec ruby bench/pure_ruby_bench.rb

# JRuby — Java ext (build jar first: bundle exec rake compile)
jruby -Ilib bench/pure_ruby_bench.rb

# JRuby — pure Ruby fallback
NATIVE=0 jruby -Ilib bench/pure_ruby_bench.rb

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants