require 'open3' class Certificate < ApplicationRecord include Versions include Certificate::CertificateConcern belongs_to :api_user SIGNED = 'signed'.freeze UNSIGNED = 'unsigned'.freeze EXPIRED = 'expired'.freeze REVOKED = 'revoked'.freeze VALID = 'valid'.freeze API = 'api'.freeze REGISTRAR = 'registrar'.freeze INTERFACES = [API, REGISTRAR].freeze scope 'api', -> { where(interface: API) } scope 'registrar', -> { where(interface: REGISTRAR) } scope 'unrevoked', -> { where(revoked: false) } validate :validate_csr_and_crt_presence def validate_csr_and_crt_presence return if csr.try(:scrub).present? || crt.try(:scrub).present? errors.add(:base, I18n.t(:crt_or_csr_must_be_present)) end validate :validate_csr_and_crt def validate_csr_and_crt parsed_crt parsed_csr rescue OpenSSL::X509::RequestError, OpenSSL::X509::CertificateError errors.add(:base, I18n.t(:invalid_csr_or_crt)) end validate :assign_metadata, on: :create def assign_metadata return if errors.any? parse_metadata(certificate_origin) rescue NoMethodError errors.add(:base, I18n.t(:invalid_csr_or_crt)) end def parsed_crt @p_crt ||= OpenSSL::X509::Certificate.new(crt) if crt end def parsed_csr @p_csr ||= OpenSSL::X509::Request.new(csr) if csr end def parsed_private_key return nil if private_key.blank? decoded_key = Base64.decode64(private_key) OpenSSL::PKey::RSA.new(decoded_key, Certificates::CertificateGenerator.ca_password) rescue OpenSSL::PKey::RSAError nil end # def parsed_p12 # return nil if p12.blank? # decoded_p12 = Base64.decode64(p12) # OpenSSL::PKCS12.new(decoded_p12) # rescue OpenSSL::PKCS12::PKCS12Error # nil # end def parsed_p12 return nil if p12.blank? decoded_p12 = Base64.decode64(p12) OpenSSL::PKCS12.new(decoded_p12, '123456') rescue OpenSSL::PKCS12::PKCS12Error => e Rails.logger.error("Ошибка разбора PKCS12: #{e.message}") nil end def revoked? status == REVOKED end def revokable? interface == REGISTRAR && status != UNSIGNED end def status return UNSIGNED if crt.blank? return @cached_status if @cached_status @cached_status = SIGNED if certificate_expired? @cached_status = EXPIRED elsif revoked || certificate_revoked? @cached_status = REVOKED end @cached_status end def sign!(password:) csr_file = create_tempfile('client_csr', csr) crt_file = Tempfile.new('client_crt') err_output = execute_openssl_sign_command(password, csr_file.path, crt_file.path) update_certificate_details(crt_file) and return true if err_output.match?(/Data Base Updated/) log_failed_to_create_certificate(err_output) false end def revoke!(password:) crt_file = create_tempfile('client_crt', crt) err_output = execute_openssl_revoke_command(password, crt_file.path) if revocation_successful?(err_output) update_revocation_status self.class.update_crl return self end handle_revocation_failure(err_output) end def renewable? return false if revoked? return false if crt.blank? return false if expires_at.blank? expires_at > Time.current && expires_at <= 30.days.from_now end def expired? return false if revoked? return false if crt.blank? return false if expires_at.blank? expires_at < Time.current end def renew raise "Certificate cannot be renewed" unless renewable? generator = Certificates::CertificateGenerator.new( username: api_user.username, registrar_code: api_user.registrar_code, registrar_name: api_user.registrar_name, certificate: self ) generator.renew_certificate end def self.generate_for_api_user(api_user:) generator = Certificates::CertificateGenerator.new( username: api_user.username, registrar_code: api_user.registrar_code, registrar_name: api_user.registrar_name ) cert_data = generator.call create!( api_user: api_user, interface: 'api', private_key: Base64.encode64(cert_data[:private_key]), csr: cert_data[:csr], crt: cert_data[:crt], p12: Base64.encode64(cert_data[:p12]), expires_at: cert_data[:expires_at] ) end def self.diagnose_crl_issue crl_path = "#{ENV['crl_dir']}/crl.pem" Rails.logger.info("CRL path: #{crl_path}") Rails.logger.info("CRL exists: #{File.exist?(crl_path)}") return false unless File.exist?(crl_path) crl = OpenSSL::X509::CRL.new(File.open(crl_path).read) Rails.logger.info("CRL issuer: #{crl.issuer}") Rails.logger.info("CRL last update: #{crl.last_update}") Rails.logger.info("CRL next update: #{crl.next_update}") Rails.logger.info("CRL revoked certificates count: #{crl.revoked.size}") if crl.revoked.any? crl.revoked.each do |revoked| Rails.logger.info("Revoked serial: #{revoked.serial}, time: #{revoked.time}") end end true rescue => e Rails.logger.error("Error parsing CRL file: #{e.message}") false end def self.regenerate_crl ca_base_path = ENV['ca_dir'] || Rails.root.join('certs', 'ca').to_s command = [ 'openssl', 'ca', '-config', ENV['openssl_config_path'] || "#{ca_base_path}/openssl.cnf", '-keyfile', ENV['ca_key_path'] || "#{ca_base_path}/private/ca.key.pem", '-cert', ENV['ca_cert_path'] || "#{ca_base_path}/certs/ca.crt.pem", '-crldays', '3650', '-gencrl', '-out', ENV['crl_path'] || "#{ca_base_path}/crl/crl.pem" ] output, error, status = Open3.capture3(*command) if status.success? Rails.logger.info("CRL regenerated successfully") true else Rails.logger.error("Failed to regenerate CRL: #{error}") false end rescue => e Rails.logger.error("Error in regenerate_crl: #{e.message}") false end private def certificate_origin crt ? parsed_crt : parsed_csr end def parse_metadata(origin) pc = origin.subject.to_s cn = pc.scan(%r{\/CN=(.+)}).flatten.first self.common_name = cn.split('/').first self.md5 = OpenSSL::Digest::MD5.new(origin.to_der).to_s if crt self.interface = crt ? API : REGISTRAR end def create_tempfile(filename, content = '') tempfile = Tempfile.new(filename) tempfile.write(content) tempfile.rewind tempfile end def log_failed_to_create_certificate(err_output) logger.error('FAILED TO CREATE CLIENT CERTIFICATE') if err_output.match?(/TXT_DB error number 2/) handle_csr_already_signed_error else errors.add(:base, I18n.t('failed_to_create_certificate')) end logger.error(err_output) puts "Certificate sign issue: #{err_output.inspect}" if Rails.env.test? end def execute_openssl_sign_command(password, csr_path, crt_path) openssl_command = [ 'openssl', 'ca', '-config', ENV['openssl_config_path'], '-keyfile', ENV['ca_key_path'], '-cert', ENV['ca_cert_path'], '-extensions', 'usr_cert', '-notext', '-md', 'sha256', '-in', csr_path, '-out', crt_path, '-key', password, '-batch' ] _out, err, _st = Open3.capture3(*openssl_command) err end def execute_openssl_revoke_command(password, crt_path) openssl_command = [ 'openssl', 'ca', '-config', ENV['openssl_config_path'], '-keyfile', ENV['ca_key_path'], '-cert', ENV['ca_cert_path'], '-revoke', crt_path, '-key', password, '-batch' ] _out, err, _st = Open3.capture3(*openssl_command) err end def update_certificate_details(crt_file) crt_file.rewind self.crt = crt_file.read self.md5 = OpenSSL::Digest::MD5.new(parsed_crt.to_der).to_s save! end def handle_csr_already_signed_error errors.add(:base, I18n.t('failed_to_create_crt_csr_already_signed')) logger.error('CSR ALREADY SIGNED') end def handle_revocation_failure(err_output) errors.add(:base, I18n.t('failed_to_revoke_certificate')) logger.error('FAILED TO REVOKE CLIENT CERTIFICATE') logger.error(err_output) false end def revocation_successful?(err_output) err_output.match?(/Data Base Updated/) || err_output.match?(/ERROR:Already revoked/) end def update_revocation_status self.revoked = true save! @cached_status = REVOKED end def certificate_expired? parsed_crt.not_before > Time.zone.now.utc && parsed_crt.not_after < Time.zone.now.utc end def certificate_revoked? return true if revoked crl_path = "#{ENV['crl_dir']}/crl.pem" return false unless File.exist?(crl_path) crl = OpenSSL::X509::CRL.new(File.open(crl_path).read) if crl.next_update && crl.next_update < Time.now Rails.logger.warn("CRL file is expired! next_update: #{crl.next_update}") return false end cert_serial = parsed_crt.serial is_revoked = crl.revoked.map(&:serial).include?(cert_serial) if is_revoked Rails.logger.warn("Certificate with serial #{cert_serial} found in CRL!") end is_revoked rescue => e Rails.logger.error("Error checking CRL: #{e.message}") false end end