2FA OTP migration - Algorithms, encoding, and exporting keys

Hi all,

I’m looking for some pointers if possible around a 2FA migration that the org I work for is looking to do. We’re looking to export the OTP secret keys from GitLab to Keycloak as we are migrating our login services that were previously based in a WCMS.

Using lib/gitlab/otp_key_rotator.rb · master · GitLab.org / GitLab · GitLab as a base, I had made a small script that takes the encrypted GitLab OTP keys and exports them to a CSV file as decrypted strings. I’ve included this script at the end for reference. These values are base64 encoded, so we decode that to get the original TOTP key which I’ve verified through comparing base values.

Investigating Keycloak, they store the base32 decoded value of the key in the database, which comes out to a nice looking key like 3iVT7weJLMANE4vNEyyu for their keys, where GitLab keys come out to UTF-8 junk characters which crash the system. I’m trying to figure out if I’m missing something, or if there is a fundamental incompatibility between the systems and how they build their authenticator keys. If anyone has insight or experience in doing this, I’d much appreciate any information you can provide!

Additionally, if anyone knows, what is the HMAC algorithm used for the keys in GitLab? I’m trying to reduce differences where possible, and I can’t find a clear answer digging in the code or documentation.

Decryption code used
class OtpDecryptReport

  # csv file headers
  HEADERS = %w[user_id username secret].freeze

  attr_reader :key_base

  def initialize(key_base)
    @key_base = key_base
  end

  def reportBuild!()
    write_csv do |csv|
      User.with_two_factor.in_batches do |relation|
        rows = relation.pluck(:id, :encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt, :username)
        rows.each do |row|
          user = %i[id secret iv salt username].zip(row).to_h
          new_value = decryptUserOtpKey(user)
          csv << [user[:id], user[:username], new_value]
        end
      end
    end
  end

  # lifted from otp_key_rotator class, fetches crpyt attrs once
  def otp_secret_settings
    @otp_secret_settings ||= User.attr_encrypted_attributes[:otp_secret]
  end

  # Labels on the tin.
  def decryptUserOtpKey(user)

    # build the opts for decryption of 2fa key
    original = user[:secret].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]
    }

    # decrypt the cipher'd text and output
    decrypted = Encryptor.decrypt(original, opts.merge(key: key_base))
    [decrypted].pack("m")
  end

  # This saves raw secrets, so don't be stupid, delete it once you make a local copy
  def write_csv(&blk)
    File.open("/tmp/keys.csv", "w") do |file|
      yield CSV.new(file, headers: HEADERS, write_headers: false)
    end
  end
end
# actually run the report
OtpDecryptReport.new('otp_base_key value').reportBuild!