BigW Consortium Gitlab

otp_key_rotator.rb 2.93 KB
module Gitlab
  # The +otp_key_base+ param is used to encrypt the User#otp_secret attribute.
  #
  # When +otp_key_base+ is changed, it invalidates the current encrypted values
  # of User#otp_secret. This class can be used to decrypt all the values with
  # the old key, encrypt them with the new key, and and update the database
  # with the new values.
  #
  # For persistence between runs, a CSV file is used with the following columns:
  #
  #   user_id, old_value, new_value
  #
  # Only the encrypted values are stored in this file.
  #
  # As users may have their 2FA settings changed at any time, this is only
  # guaranteed to be safe if run offline.
  class OtpKeyRotator
    HEADERS = %w[user_id old_value new_value].freeze

    attr_reader :filename

    # Create a new rotator. +filename+ is used to store values by +calculate!+,
    # and to update the database with new and old values in +apply!+ and
    # +rollback!+, respectively.
    def initialize(filename)
      @filename = filename
    end

    def rotate!(old_key:, new_key:)
      old_key ||= Gitlab::Application.secrets.otp_key_base

      raise ArgumentError.new("Old key is the same as the new key") if old_key == new_key
      raise ArgumentError.new("New key is too short! Must be 256 bits") if new_key.size < 64

      write_csv do |csv|
        ActiveRecord::Base.transaction do
          User.with_two_factor.in_batches do |relation| # rubocop: disable Cop/InBatches
            rows = relation.pluck(:id, :encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt)
            rows.each do |row|
              user = %i[id ciphertext iv salt].zip(row).to_h
              new_value = reencrypt(user, old_key, new_key)

              User.where(id: user[:id]).update_all(encrypted_otp_secret: new_value)
              csv << [user[:id], user[:ciphertext], new_value]
            end
          end
        end
      end
    end

    def rollback!
      ActiveRecord::Base.transaction do
        CSV.foreach(filename, headers: HEADERS, return_headers: false) do |row|
          User.where(id: row['user_id']).update_all(encrypted_otp_secret: row['old_value'])
        end
      end
    end

    private

    attr_reader :old_key, :new_key

    def otp_secret_settings
      @otp_secret_settings ||= User.encrypted_attributes[:otp_secret]
    end

    def reencrypt(user, old_key, new_key)
      original = user[:ciphertext].unpack("m").join
      opts = {
        iv: user[:iv].unpack("m").join,
        salt: user[:salt].unpack("m").join,
        algorithm: otp_secret_settings[:algorithm],
        insecure_mode: otp_secret_settings[:insecure_mode]
      }

      decrypted = Encryptor.decrypt(original, opts.merge(key: old_key))
      encrypted = Encryptor.encrypt(decrypted, opts.merge(key: new_key))
      [encrypted].pack("m")
    end

    def write_csv(&blk)
      File.open(filename, "w") do |file|
        yield CSV.new(file, headers: HEADERS, write_headers: false)
      end
    end
  end
end