From b5e8558ca18ca14cb438fa372ecaf824dafc9d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=8A=E5=B7=9D=20=E4=BD=B3=E7=A5=90?= <0421kossy0421@gmail.com> Date: Sat, 29 Nov 2025 21:05:41 +0900 Subject: [PATCH] sha256 password before passing to bcrypt to avoid issues with 72 bytes truncation for passwords --- lib/devise/encryptor.rb | 34 ++++++++++++++++++++++++++++++---- test/encrytor_test.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 test/encrytor_test.rb diff --git a/lib/devise/encryptor.rb b/lib/devise/encryptor.rb index 7a53bef309..4d755204ca 100644 --- a/lib/devise/encryptor.rb +++ b/lib/devise/encryptor.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'bcrypt' +require 'digest' module Devise module Encryptor @@ -8,17 +9,42 @@ def self.digest(klass, password) if klass.pepper.present? password = "#{password}#{klass.pepper}" end + # This converts the password (of any length) into a fixed + # 64-character hex string, safely under the 72-char limit + password = Digest::SHA256.hexdigest(password) + + # BCrypt the pre-hashed string ::BCrypt::Password.create(password, cost: klass.stretches).to_s end + # Compares a potential password with a stored hash. + # + # It attempts the new (SHA-256 -> BCrypt) method first. + # If that fails, it falls back to the old (direct BCrypt) method + # to support existing passwords that were not pre-hashed def self.compare(klass, hashed_password, password) return false if hashed_password.blank? - bcrypt = ::BCrypt::Password.new(hashed_password) + + begin + bcrypt = ::BCrypt::Password.new(hashed_password) + rescue ::BCrypt::Errors::InvalidHash + return false + end + if klass.pepper.present? password = "#{password}#{klass.pepper}" end - password = ::BCrypt::Engine.hash_secret(password, bcrypt.salt) - Devise.secure_compare(password, hashed_password) + + # This is for passwords created with the new `digest` method. + pre_hashed_password = Digest::SHA256.hexdigest(password) + new_style_hash = ::BCrypt::Engine.hash_secret(pre_hashed_password, bcrypt.salt) + + return true if Devise.secure_compare(new_style_hash, hashed_password) + + # This is for passwords created before this change + # We re-run the original logic + old_style_hash = ::BCrypt::Engine.hash_secret(password, bcrypt.salt) + Devise.secure_compare(old_style_hash, hashed_password) end end -end +end \ No newline at end of file diff --git a/test/encrytor_test.rb b/test/encrytor_test.rb new file mode 100644 index 0000000000..c73965e425 --- /dev/null +++ b/test/encrytor_test.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require 'test_helper' +require 'bcrypt' + +class EncryptorTest < ActiveSupport::TestCase + test 'digest/compare passwords' do + hashed_password = Devise::Encryptor.digest(Devise, 'example') + assert Devise::Encryptor.compare(Devise, hashed_password, 'example') + assert_not Devise::Encryptor.compare(Devise, hashed_password, 'example1') + end + + test 'false for incorrect bcrypt string' do + assert_not Devise::Encryptor.compare(Devise, 'incorrect_bcrypt_string', 'example') + end + + test 'digest/compare support passwords longer 72 bytes' do + hashed_password = Devise::Encryptor.digest(Devise, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa123') + assert Devise::Encryptor.compare(Devise, hashed_password, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa123') + assert_not Devise::Encryptor.compare(Devise, hashed_password, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa125') + end + + test 'digest/compare support old bcrypt only passwords' do + password = 'example' + password_with_pepper = "#{password}#{Devise.pepper}" + old_hashed_password =::BCrypt::Password.create(password_with_pepper, cost: Devise.stretches) + + assert Devise::Encryptor.compare(Devise, old_hashed_password, password) + assert_not Devise::Encryptor.compare(Devise, old_hashed_password, 'examplo') + end +end \ No newline at end of file