diff --git a/lib/devise/encryptable/encryptable.rb b/lib/devise/encryptable/encryptable.rb index 441ed4e..65dd8c4 100644 --- a/lib/devise/encryptable/encryptable.rb +++ b/lib/devise/encryptable/encryptable.rb @@ -19,6 +19,8 @@ module Devise module Encryptable module Encryptors + InvalidHash = Class.new(StandardError) + autoload :AuthlogicSha512, 'devise/encryptable/encryptors/authlogic_sha512' autoload :Base, 'devise/encryptable/encryptors/base' autoload :ClearanceSha1, 'devise/encryptable/encryptors/clearance_sha1' diff --git a/lib/devise/encryptable/encryptors/pbkdf2.rb b/lib/devise/encryptable/encryptors/pbkdf2.rb index cf0a3d8..bbdf75e 100644 --- a/lib/devise/encryptable/encryptors/pbkdf2.rb +++ b/lib/devise/encryptable/encryptors/pbkdf2.rb @@ -1,23 +1,66 @@ -begin - module Devise - module Encryptable - module Encryptors - class Pbkdf2 < Base - def self.compare(encrypted_password, password, stretches, salt, pepper) - value_to_test = self.digest(password, stretches, salt, pepper) - Devise.secure_compare(encrypted_password, value_to_test) - end - - def self.digest(password, stretches, salt, pepper) - hash = OpenSSL::Digest.new('SHA512').new - OpenSSL::KDF.pbkdf2_hmac( - password.to_s, - salt: "#{[salt].pack('H*')}#{pepper}", - iterations: stretches, - hash: hash, - length: hash.digest_length, - ).unpack1('H*') - end +module Devise + module Encryptable + module Encryptors + # https://en.wikipedia.org/wiki/PBKDF2 + # Adapted from https://gitlab.com/gitlab-org/gitlab/-/blob/373f088e755f678478b8dd1627fab908d2641b21/vendor/gems/devise-pbkdf2-encryptable/lib/devise/pbkdf2_encryptable/encryptors/pbkdf2_sha512.rb + class Pbkdf2 < Base + STRATEGY = 'pbkdf2-sha512' + + # since stretches and iterations are part of the hashed pass, so ignore them during comparing + def self.compare(encrypted_password, password, _stretches, _salt, pepper) + split_digest = self.split_digest(encrypted_password) + value_to_test = sha512_checksum(password, split_digest[:stretches], split_digest[:salt], pepper) + + Devise.secure_compare(split_digest[:checksum], value_to_test) + end + + def self.digest(password, stretches, salt, pepper) + checksum = sha512_checksum(password, stretches, salt, pepper) + + format_hash(STRATEGY, stretches, salt, checksum) + end + + private_class_method def self.sha512_checksum(password, stretches, salt, pepper) + hash = OpenSSL::Digest.new('SHA512') + pbkdf2_checksum(hash, password, stretches, salt, pepper) + end + + private_class_method def self.pbkdf2_checksum(hash, password, stretches, salt, pepper) + OpenSSL::KDF.pbkdf2_hmac( + password.to_s, + salt: "#{[salt].pack('H*')}#{pepper}", + iterations: stretches, + hash: hash, + length: hash.digest_length + ).unpack1('H*') + end + + # Passlib-style hash: $pbkdf2-sha512$rounds$salt$checksum + # where salt and checksum are "adapted" Base64 encoded + private_class_method def self.format_hash(strategy, stretches, salt, checksum) + encoded_salt = passlib_encode64(salt) + encoded_checksum = passlib_encode64(checksum) + + "$#{strategy}$#{stretches}$#{encoded_salt}$#{encoded_checksum}" + end + + private_class_method def self.passlib_encode64(value) + Base64.strict_encode64([value].pack('H*')).tr('+', '.').delete('=') + end + + private_class_method def self.passlib_decode64(value) + enc = value.tr('.', '+') + Base64.decode64(enc).unpack1('H*') + end + + private_class_method def self.split_digest(hash) + split_digest = hash.split('$') + _, strategy, stretches, salt, checksum = split_digest + + raise InvalidHash, 'invalid PBKDF2 hash' unless split_digest.length == 5 && strategy.start_with?('pbkdf2-') + + { strategy: strategy, stretches: stretches.to_i, + salt: passlib_decode64(salt), checksum: passlib_decode64(checksum) } end end end diff --git a/test/devise/encryptable/encryptors/pbkdf2_test.rb b/test/devise/encryptable/encryptors/pbkdf2_test.rb new file mode 100644 index 0000000..21c5146 --- /dev/null +++ b/test/devise/encryptable/encryptors/pbkdf2_test.rb @@ -0,0 +1,61 @@ +require 'test_helper' +require 'benchmark' + +class PBKDF2Test < ActiveSupport::TestCase + include Support::Assertions + include Support::Factories + include Support::Swappers + + STETCHES = 210_000 + PEPPER = 'thisisasuperlongstringusedontopofsalt'.freeze + + def encrypt_password(admin, pepper = Admin.pepper, stretches = Admin.stretches, encryptor = Admin.encryptor_class) + encryptor.digest('123456', stretches, admin.password_salt, pepper) + end + + def random_salt + Devise::Encryptable::Encryptors::Base.salt(STETCHES) + end + + test 'digest and compare success' do + plain_pass = 'password1' + hashed_password = Devise::Encryptable::Encryptors::Pbkdf2.digest(plain_pass, STETCHES, random_salt, PEPPER) + assert Devise::Encryptable::Encryptors::Pbkdf2.compare(hashed_password, plain_pass, nil, nil, PEPPER) + end + + test 'invalid password hash raise' do + plain_pass = 'password1' + assert_raise(Devise::Encryptable::Encryptors::InvalidHash) do + Devise::Encryptable::Encryptors::Pbkdf2.compare('wrongformatpasshash', plain_pass, nil, nil, PEPPER) + end + end + + test 'wrong password' do + plain_pass = 'password1' + hashed_password = Devise::Encryptable::Encryptors::Pbkdf2.digest(plain_pass, 210_000, random_salt, PEPPER) + assert !Devise::Encryptable::Encryptors::Pbkdf2.compare(hashed_password, 'wrongpass', nil, nil, PEPPER) + end + + test 'changed pepper will fail password check' do + plain_pass = 'password1' + hashed_password = Devise::Encryptable::Encryptors::Pbkdf2.digest(plain_pass, 210_000, random_salt, PEPPER) + assert !Devise::Encryptable::Encryptors::Pbkdf2.compare(hashed_password, plain_pass, nil, nil, + 'opps, different pepper') + end + + test 'devise using Pbkdf2' do + swap_with_encryptor Admin, :Pbkdf2 do + admin = create_admin + assert_equal admin.encrypted_password, + encrypt_password(admin, Admin.pepper, Admin.stretches, Devise::Encryptable::Encryptors::Pbkdf2) + end + end + + test 'devise can compare using Pbkdf2' do + swap_with_encryptor Admin, :Pbkdf2 do + plain_pass = 'password1' + admin = create_admin(password: plain_pass) + assert admin.valid_password?(plain_pass) + end + end +end