Files
sure/db/migrate/20260503180000_rehash_plaintext_mfa_backup_codes.rb
ghost d0883f9018 fix(auth): hash MFA backup codes (#1629)
* fix(auth): hash MFA backup codes

* fix(auth): lock and filter backup code verification

* test(auth): assert consumed backup code digest

* fix(auth): strengthen backup code handling

* fix(auth): require otp secret before mfa enable

* test(auth): assert backup code digest consumption

* fix(auth): rehash legacy MFA backup codes

* fix(auth): narrow legacy backup code migration
2026-05-05 01:20:57 +02:00

52 lines
1.5 KiB
Ruby

# frozen_string_literal: true
class RehashPlaintextMfaBackupCodes < ActiveRecord::Migration[7.2]
class MigrationUser < ActiveRecord::Base
self.table_name = "users"
end
BCRYPT_PREFIXES = %w[$2a$ $2b$ $2y$].freeze
PLAINTEXT_BACKUP_CODE_PATTERN = /\A[0-9a-f]{8}\z/
def up
require "bcrypt"
say_with_time "Rehashing plaintext MFA backup codes" do
rehashed_users_count = 0
MigrationUser.where(otp_required: true).find_each do |user|
backup_codes = Array(user.otp_backup_codes)
next if backup_codes.blank?
next unless backup_codes.any? { |code| plaintext_backup_code?(code) }
rehashed_codes = backup_codes.map do |code|
plaintext_backup_code?(code) ? BCrypt::Password.create(normalize_backup_code(code), cost: bcrypt_cost).to_s : code
end
user.update_columns(otp_backup_codes: rehashed_codes, updated_at: Time.current)
rehashed_users_count += 1
end
rehashed_users_count
end
end
def down
raise ActiveRecord::IrreversibleMigration
end
private
def plaintext_backup_code?(code)
normalized_code = normalize_backup_code(code)
normalized_code.match?(PLAINTEXT_BACKUP_CODE_PATTERN) && BCRYPT_PREFIXES.none? { |prefix| normalized_code.start_with?(prefix) }
end
def normalize_backup_code(code)
code.to_s.strip.downcase
end
def bcrypt_cost
ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
end
end