Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/devise/encryptable/encryptable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
83 changes: 63 additions & 20 deletions lib/devise/encryptable/encryptors/pbkdf2.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my primary discomfort with this is the potential for a name with devise encryptable clash if/when the original PR gets merged. Maybe change the class name to something like Pbkdf2Hash?

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
Expand Down
61 changes: 61 additions & 0 deletions test/devise/encryptable/encryptors/pbkdf2_test.rb
Original file line number Diff line number Diff line change
@@ -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