# frozen_string_literal: true

# X509CertificateCredentialsValidator
#
# Custom validator to check if certificate-attribute was signed using the
# private key stored in an attrebute.
#
# This can be used as an `ActiveModel::Validator` as follows:
#
#   validates_with X509CertificateCredentialsValidator,
#                  certificate: :client_certificate,
#                  pkey: :decrypted_private_key,
#                  pass: :decrypted_passphrase
#
#
# Required attributes:
# - certificate: The name of the accessor that returns the certificate to check
# - pkey: The name of the accessor that returns the private key
# Optional:
# - pass: The name of the accessor that returns the passphrase to decrypt the
#         private key
class X509CertificateCredentialsValidator < ActiveModel::Validator
  def initialize(*args)
    super

    # We can't validate if we don't have a private key or certificate attributes
    # in which case this validator is useless.
    if options[:pkey].nil? || options[:certificate].nil?
      raise 'Provide at least `certificate` and `pkey` attribute names'
    end
  end

  def validate(record)
    unless certificate = read_certificate(record)
      record.errors.add(options[:certificate], _('is not a valid X509 certificate.'))
    end

    unless private_key = read_private_key(record)
      record.errors.add(options[:pkey], _('could not read private key, is the passphrase correct?'))
    end

    return if private_key.nil? || certificate.nil?

    unless certificate.public_key.fingerprint == private_key.public_key.fingerprint
      record.errors.add(options[:pkey], _('private key does not match certificate.'))
    end
  end

  private

  def read_private_key(record)
    OpenSSL::PKey.read(pkey(record).to_s, pass(record).to_s)
  rescue OpenSSL::PKey::PKeyError, ArgumentError
    # When the primary key could not be read, an ArgumentError is raised.
    # This hapens when the passed key is not valid or the passphrase is incorrect
    nil
  end

  def read_certificate(record)
    OpenSSL::X509::Certificate.new(certificate(record).to_s)
  rescue OpenSSL::X509::CertificateError
    nil
  end

  # rubocop:disable GitlabSecurity/PublicSend
  #
  # Allowing `#public_send` here because we don't want the validator to really
  # care about the names of the attributes or where they come from.
  #
  # The credentials are mostly stored encrypted so we need to go through the
  # accessors to get the values, `read_attribute` bypasses those.
  def certificate(record)
    record.public_send(options[:certificate])
  end

  def pkey(record)
    record.public_send(options[:pkey])
  end

  def pass(record)
    return unless options[:pass]

    record.public_send(options[:pass])
  end
  # rubocop:enable GitlabSecurity/PublicSend
end
