/lib/gitlab/otp_key_rotator.rb

https://gitlab.com/blackst0ne/gitlab-ce · Ruby · 87 lines · 56 code · 13 blank · 18 comment · 3 complexity · 10096ab95d177587aaaa7418b2f9de40 MD5 · raw file

  1. module Gitlab
  2. # The +otp_key_base+ param is used to encrypt the User#otp_secret attribute.
  3. #
  4. # When +otp_key_base+ is changed, it invalidates the current encrypted values
  5. # of User#otp_secret. This class can be used to decrypt all the values with
  6. # the old key, encrypt them with the new key, and and update the database
  7. # with the new values.
  8. #
  9. # For persistence between runs, a CSV file is used with the following columns:
  10. #
  11. # user_id, old_value, new_value
  12. #
  13. # Only the encrypted values are stored in this file.
  14. #
  15. # As users may have their 2FA settings changed at any time, this is only
  16. # guaranteed to be safe if run offline.
  17. class OtpKeyRotator
  18. HEADERS = %w[user_id old_value new_value].freeze
  19. attr_reader :filename
  20. # Create a new rotator. +filename+ is used to store values by +calculate!+,
  21. # and to update the database with new and old values in +apply!+ and
  22. # +rollback!+, respectively.
  23. def initialize(filename)
  24. @filename = filename
  25. end
  26. def rotate!(old_key:, new_key:)
  27. old_key ||= Gitlab::Application.secrets.otp_key_base
  28. raise ArgumentError.new("Old key is the same as the new key") if old_key == new_key
  29. raise ArgumentError.new("New key is too short! Must be 256 bits") if new_key.size < 64
  30. write_csv do |csv|
  31. ActiveRecord::Base.transaction do
  32. User.with_two_factor.in_batches do |relation| # rubocop: disable Cop/InBatches
  33. rows = relation.pluck(:id, :encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt)
  34. rows.each do |row|
  35. user = %i[id ciphertext iv salt].zip(row).to_h
  36. new_value = reencrypt(user, old_key, new_key)
  37. User.where(id: user[:id]).update_all(encrypted_otp_secret: new_value)
  38. csv << [user[:id], user[:ciphertext], new_value]
  39. end
  40. end
  41. end
  42. end
  43. end
  44. def rollback!
  45. ActiveRecord::Base.transaction do
  46. CSV.foreach(filename, headers: HEADERS, return_headers: false) do |row|
  47. User.where(id: row['user_id']).update_all(encrypted_otp_secret: row['old_value'])
  48. end
  49. end
  50. end
  51. private
  52. attr_reader :old_key, :new_key
  53. def otp_secret_settings
  54. @otp_secret_settings ||= User.encrypted_attributes[:otp_secret]
  55. end
  56. def reencrypt(user, old_key, new_key)
  57. original = user[:ciphertext].unpack("m").join
  58. opts = {
  59. iv: user[:iv].unpack("m").join,
  60. salt: user[:salt].unpack("m").join,
  61. algorithm: otp_secret_settings[:algorithm],
  62. insecure_mode: otp_secret_settings[:insecure_mode]
  63. }
  64. decrypted = Encryptor.decrypt(original, opts.merge(key: old_key))
  65. encrypted = Encryptor.encrypt(decrypted, opts.merge(key: new_key))
  66. [encrypted].pack("m")
  67. end
  68. def write_csv(&blk)
  69. File.open(filename, "w") do |file|
  70. yield CSV.new(file, headers: HEADERS, write_headers: false)
  71. end
  72. end
  73. end
  74. end