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!