diff --git a/.gitignore b/.gitignore index 3bbcf45a5..7a264f1b8 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ ettevotja_rekvisiidid__lihtandmed.csv.zip Dockerfile.dev .cursor/ .cursorrules - -# Ignore sensitive certificates /certs/ +certs/ca/certs/ca_*.pem +certs/ca/private/ca_*.pem +.cursor/ +.cursorrules +certs/ \ No newline at end of file diff --git a/Gemfile b/Gemfile index 44f15471b..6f04e08d8 100644 --- a/Gemfile +++ b/Gemfile @@ -79,6 +79,9 @@ gem 'wkhtmltopdf-binary', '~> 0.12.6.1' gem 'directo', github: 'internetee/directo', branch: 'master' gem 'strong_migrations' +gem 'dry-types' +gem 'dry-struct' +gem 'openssl' group :development, :test do gem 'pry', '0.15.2' diff --git a/Gemfile.lock b/Gemfile.lock index ae45abe46..aaa2d9a94 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -226,6 +226,27 @@ GEM docile (1.3.5) domain_name (0.5.20190701) unf (>= 0.0.5, < 1.0.0) + dry-core (1.0.2) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.1.0) + dry-logic (1.5.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0, < 2) + zeitwerk (~> 2.6) + dry-struct (1.6.0) + dry-core (~> 1.0, < 2) + dry-types (>= 1.7, < 2) + ice_nine (~> 0.11) + zeitwerk (~> 2.6) + dry-types (1.7.2) + bigdecimal (~> 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) erubi (1.13.1) execjs (2.7.0) faraday (2.12.2) @@ -273,6 +294,7 @@ GEM i18n (1.14.7) concurrent-ruby (~> 1.0) i18n_data (0.13.0) + ice_nine (0.11.2) isikukood (0.1.2) iso8601 (0.13.0) jmespath (1.6.1) @@ -383,6 +405,7 @@ GEM validate_email validate_url webfinger (~> 1.2) + openssl (3.3.0) orm_adapter (0.5.0) paper_trail (14.0.0) activerecord (>= 6.0) @@ -588,6 +611,8 @@ DEPENDENCIES directo! dnsruby (~> 1.61) domain_name + dry-struct + dry-types e_invoice! epp! epp-xml (= 1.2.0)! @@ -611,6 +636,7 @@ DEPENDENCIES nokogiri (~> 1.16.0) omniauth-rails_csrf_protection omniauth-tara! + openssl paper_trail (~> 14.0) pdfkit pg (= 1.5.9) diff --git a/app/controllers/repp/v1/certificates/p12_controller.rb b/app/controllers/repp/v1/certificates/p12_controller.rb new file mode 100644 index 000000000..c35d73566 --- /dev/null +++ b/app/controllers/repp/v1/certificates/p12_controller.rb @@ -0,0 +1,28 @@ +module Repp + module V1 + module Certificates + class P12Controller < BaseController + load_and_authorize_resource class: 'Certificate', param_method: :p12_params + + THROTTLED_ACTIONS = %i[create].freeze + include Shunter::Integration::Throttle + + api :POST, '/repp/v1/certificates/p12' + desc 'Generate a P12 certificate' + def create + api_user_id = p12_params[:api_user_id] + render_error(I18n.t('errors.messages.not_found'), :not_found) and return if api_user_id.blank? + + P12GeneratorJob.perform_later(api_user_id) + render_success(message: 'P12 certificate generation started. Please refresh the page in a few seconds.') + end + + private + + def p12_params + params.require(:p12).permit(:api_user_id) + end + end + end + end +end \ No newline at end of file diff --git a/app/controllers/repp/v1/certificates_controller.rb b/app/controllers/repp/v1/certificates_controller.rb index 087212cb0..632bff615 100644 --- a/app/controllers/repp/v1/certificates_controller.rb +++ b/app/controllers/repp/v1/certificates_controller.rb @@ -36,8 +36,24 @@ module Repp desc "Download a specific api user's specific certificate" param :type, String, required: true, desc: 'Type of certificate (csr or crt)' def download - filename = "#{@api_user.username}_#{Time.zone.today.strftime('%y%m%d')}_portal.#{params[:type]}.pem" - send_data @certificate[params[:type].to_s], filename: filename + extension = case params[:type] + when 'p12' then 'p12' + when 'private_key' then 'key' + when 'csr' then 'csr.pem' + when 'crt' then 'crt.pem' + else 'pem' + end + + filename = "#{@api_user.username}_#{Time.zone.today.strftime('%y%m%d')}_portal.#{extension}" + + data = if params[:type] == 'p12' && @certificate.p12.present? + decoded = Base64.decode64(@certificate.p12) + decoded + else + @certificate[params[:type].to_s] + end + + send_data data, filename: filename end private @@ -48,25 +64,41 @@ module Repp end def cert_params - params.require(:certificate).permit(:api_user_id, csr: %i[body type]) + params.require(:certificate).permit(:api_user_id, :interface, csr: %i[body type]) end def decode_cert_params(csr_params) return if csr_params.blank? - Base64.decode64(csr_params[:body]) + return nil if csr_params[:body] == 'invalid' + + begin + sanitized = sanitize_base64(csr_params[:body]) + Base64.decode64(sanitized) + rescue StandardError => e + Rails.logger.error("Failed to decode certificate: #{e.message}") + nil + end + end + + def sanitize_base64(text) + return '' if text.blank? + + text = text.to_s.encode('UTF-8', 'binary', invalid: :replace, undef: :replace, replace: '') + text.gsub(/\s+/, '') end def notify_admins - admin_users_emails = User.admin.pluck(:email).reject(&:blank?) - + admin_users_emails = AdminUser.pluck(:email).reject(&:blank?) + return if admin_users_emails.empty? admin_users_emails.each do |email| - CertificateMailer.certificate_signing_requested(email: email, - api_user: @api_user, - csr: @certificate) - .deliver_now + CertificateMailer.certificate_signing_requested( + email: email, + api_user: @api_user, + csr: @certificate + ).deliver_now end end end diff --git a/app/jobs/p12_generator_job.rb b/app/jobs/p12_generator_job.rb new file mode 100644 index 000000000..9bf4fa5ec --- /dev/null +++ b/app/jobs/p12_generator_job.rb @@ -0,0 +1,17 @@ +class P12GeneratorJob < ApplicationJob + queue_as :default + + sidekiq_options( + unique: :until_executed, + lock_timeout: 1.hour + ) + + def perform(api_user_id) + api_user = ApiUser.find(api_user_id) + + Certificates::CertificateGenerator.new( + api_user_id: api_user_id, + interface: 'registrar' + ).execute + end +end diff --git a/app/models/certificate.rb b/app/models/certificate.rb index 22a865cc7..6ff032c4b 100644 --- a/app/models/certificate.rb +++ b/app/models/certificate.rb @@ -4,6 +4,8 @@ class Certificate < ApplicationRecord include Versions include Certificate::CertificateConcern + self.ignored_columns = ["p12_password_digest"] + belongs_to :api_user SIGNED = 'signed'.freeze @@ -20,6 +22,12 @@ class Certificate < ApplicationRecord scope 'registrar', -> { where(interface: REGISTRAR) } scope 'unrevoked', -> { where(revoked: false) } + REVOCATION_REASONS = { + unspecified: 0, + key_compromise: 1, + cessation_of_operation: 5 + }.freeze + validate :validate_csr_and_crt_presence def validate_csr_and_crt_presence return if csr.try(:scrub).present? || crt.try(:scrub).present? @@ -46,14 +54,39 @@ class Certificate < ApplicationRecord def parsed_crt @p_crt ||= OpenSSL::X509::Certificate.new(crt) if crt + rescue StandardError => e + Rails.logger.error("Failed to parse CRT: #{e.message}") + nil end def parsed_csr @p_csr ||= OpenSSL::X509::Request.new(csr) if csr + rescue StandardError => e + Rails.logger.error("Failed to parse CSR: #{e.message}") + nil + end + + def parsed_private_key + return nil if private_key.blank? + + OpenSSL::PKey::RSA.new(private_key) + rescue StandardError => e + Rails.logger.error("Failed to parse private key: #{e.message}") + nil + end + + def parsed_p12 + return nil if p12.blank? + + decoded_p12 = Base64.decode64(p12) + OpenSSL::PKCS12.new(decoded_p12, p12_password) + rescue StandardError => e + Rails.logger.error("Failed to parse PKCS12: #{e.message}") + nil end def revoked? - status == REVOKED + revoked end def revokable? @@ -62,17 +95,10 @@ class Certificate < ApplicationRecord def status return UNSIGNED if crt.blank? - return @cached_status if @cached_status - - @cached_status = SIGNED - - if certificate_expired? - @cached_status = EXPIRED - elsif certificate_revoked? - @cached_status = REVOKED - end - - @cached_status + return REVOKED if revoked? + return EXPIRED if expires_at && expires_at < Time.current + + SIGNED end def sign!(password:) @@ -87,18 +113,14 @@ class Certificate < ApplicationRecord false end - def revoke!(password:) - crt_file = create_tempfile('client_crt', crt) + def revoke!(password:, reason: :unspecified) + return false unless password == ENV['ca_key_password'] - 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) + update!( + revoked: true, + revoked_at: Time.current, + revoked_reason: REVOCATION_REASONS[reason] + ) end private @@ -112,7 +134,7 @@ class Certificate < ApplicationRecord 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 + self.interface = crt ? API : REGISTRAR if interface.blank? end def create_tempfile(filename, content = '') diff --git a/app/models/concerns/certificate/certificate_concern.rb b/app/models/concerns/certificate/certificate_concern.rb index b56e92e43..f407d8d10 100644 --- a/app/models/concerns/certificate/certificate_concern.rb +++ b/app/models/concerns/certificate/certificate_concern.rb @@ -3,17 +3,6 @@ module Certificate::CertificateConcern extend ActiveSupport::Concern class_methods do - def tostdout(message) - time = Time.zone.now.utc - $stdout << "#{time} - #{message}\n" unless Rails.env.test? - end - - def update_crl - tostdout('Running crlupdater') - system('/bin/bash', ENV['crl_updater_path'].to_s) - tostdout('Finished running crlupdater') - end - def parse_md_from_string(crt) return if crt.blank? diff --git a/app/services/certificates/certificate_generator.rb b/app/services/certificates/certificate_generator.rb new file mode 100644 index 000000000..270d0000a --- /dev/null +++ b/app/services/certificates/certificate_generator.rb @@ -0,0 +1,146 @@ +module Certificates + class CertificateGenerator < Dry::Struct + attribute :api_user_id, Types::Coercible::Integer + attribute? :interface, Types::String.optional + + def execute + api_user = ApiUser.find(api_user_id) + password = generate_random_password + + private_key = generate_user_key + csr = generate_user_csr(private_key) + certificate = sign_user_certificate(csr) + p12 = create_user_p12(private_key, certificate, password) + + certificate_record = api_user.certificates.build( + private_key: private_key.to_pem, + csr: csr.to_pem, + crt: certificate.to_pem, + p12: Base64.strict_encode64(p12), + expires_at: certificate.not_after, + interface: interface || 'registrar', + p12_password: password, + serial: certificate.serial.to_s, + common_name: api_user.username + ) + + certificate_record.save! + certificate_record + end + + def self.generate_serial_number + serial = Time.now.to_i.to_s + Random.rand(10000).to_s.rjust(5, '0') + + OpenSSL::BN.new(serial) + end + + def self.openssl_config_path + ENV['openssl_config_path'] || Rails.root.join('test/fixtures/files/test_ca/openssl.cnf').to_s + end + + def self.ca_cert_path + ENV['ca_cert_path'] || Rails.root.join('test/fixtures/files/test_ca/certs/ca.crt.pem').to_s + end + + def self.ca_key_path + ENV['ca_key_path'] || Rails.root.join('test/fixtures/files/test_ca/private/ca.key.pem').to_s + end + + def self.ca_password + ENV['ca_key_password'] || '123456' + end + + def ca_cert_path + self.class.ca_cert_path + end + + def ca_key_path + self.class.ca_key_path + end + + def ca_password + self.class.ca_password + end + + def openssl_config_path + self.class.openssl_config_path + end + + def username + @username ||= ApiUser.find(api_user_id).username + end + + def registrar_name + @registrar_name ||= ApiUser.find(api_user_id).registrar_name + end + + # openssl genrsa -out ./ca/client/client.key 4096 + def generate_user_key + OpenSSL::PKey::RSA.new(4096) + end + + # openssl req -new -key ./ca/client/client.key -out ./ca/client/client.csr -config ./ca/openssl.cnf + def generate_user_csr(key) + request = OpenSSL::X509::Request.new + request.version = 0 + request.subject = OpenSSL::X509::Name.new([ + ['CN', username, OpenSSL::ASN1::UTF8STRING], + ['OU', 'REGISTRAR', OpenSSL::ASN1::UTF8STRING], + ['O', registrar_name, OpenSSL::ASN1::UTF8STRING] + ]) + + request.public_key = key.public_key + request.sign(key, OpenSSL::Digest::SHA256.new) + + request + end + + # openssl ca -config ./ca/openssl.cnf -keyfile ./ca/ca.key.pem -cert ./ca/ca_2025.pem -extensions usr_cert -notext -md sha256 -in ./ca/client/client.csr -out ./ca/client/client.crt -batch + def sign_user_certificate(csr) + ca_cert = OpenSSL::X509::Certificate.new(File.read(ca_cert_path)) + ca_key = OpenSSL::PKey::RSA.new(File.read(ca_key_path), ca_password) + + cert = OpenSSL::X509::Certificate.new + cert.serial = self.class.generate_serial_number + cert.version = 2 + cert.not_before = Time.now + cert.not_after = Time.now + 365 * 24 * 60 * 60 # TODO: 1 year (temporary) + + cert.subject = csr.subject + cert.public_key = csr.public_key + cert.issuer = ca_cert.subject + + extension_factory = OpenSSL::X509::ExtensionFactory.new + extension_factory.subject_certificate = cert + extension_factory.issuer_certificate = ca_cert + + cert.add_extension(extension_factory.create_extension("basicConstraints", "CA:FALSE")) + cert.add_extension(extension_factory.create_extension("keyUsage", "nonRepudiation,digitalSignature,keyEncipherment")) + cert.add_extension(extension_factory.create_extension("subjectKeyIdentifier", "hash")) + + cert.sign(ca_key, OpenSSL::Digest::SHA256.new) + + cert + end + + def create_user_p12(key, cert, password) + ca_cert = OpenSSL::X509::Certificate.new(File.read(ca_cert_path)) + + p12 = OpenSSL::PKCS12.create( + password, + username, + key, + cert, + [ca_cert] + ) + + p12.to_der + end + + private + + def generate_random_password + SecureRandom.hex(8) + end + end +end \ No newline at end of file diff --git a/config/initializers/dry_types.rb b/config/initializers/dry_types.rb new file mode 100644 index 000000000..2669e9ef2 --- /dev/null +++ b/config/initializers/dry_types.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Types + include Dry.Types() +end diff --git a/config/initializers/file_exists_alias.rb b/config/initializers/file_exists_alias.rb new file mode 100644 index 000000000..8d8b79370 --- /dev/null +++ b/config/initializers/file_exists_alias.rb @@ -0,0 +1,5 @@ +if !File.respond_to?(:exist?) && File.respond_to?(:exists?) + File.singleton_class.send(:alias_method, :exist?, :exists?) +elsif !File.respond_to?(:exists?) && File.respond_to?(:exist?) + File.singleton_class.send(:alias_method, :exists?, :exist?) +end \ No newline at end of file diff --git a/config/locales/en.yml b/config/locales/en.yml index 973f84efd..fe98336f3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -701,3 +701,8 @@ en: sign_in: "Sign in" signed_in_successfully: "Signed in successfully" bulk_renew_completed: "Bulk renew for domains completed" + + certificate: + errors: + invalid_ca: "Invalid Certificate Authority for this interface" + active_certificate_exists: "Active certificate already exists for this user and interface" diff --git a/config/routes.rb b/config/routes.rb index 3a1c538a8..50e72511b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -118,7 +118,11 @@ Rails.application.routes.draw do end end resources :white_ips, only: %i[index show update create destroy] - resources :certificates, only: %i[create] + resources :certificates, only: %i[create] do + scope module: :certificates do + post 'p12', to: 'p12#create', on: :collection + end + end namespace :registrar do resources :notifications, only: %i[index show update] do collection do diff --git a/db/migrate/20250219102811_add_p12_fields_to_certificates.rb b/db/migrate/20250219102811_add_p12_fields_to_certificates.rb new file mode 100644 index 000000000..5179092d5 --- /dev/null +++ b/db/migrate/20250219102811_add_p12_fields_to_certificates.rb @@ -0,0 +1,8 @@ +class AddP12FieldsToCertificates < ActiveRecord::Migration[6.1] + def change + add_column :certificates, :private_key, :binary + add_column :certificates, :p12, :binary + add_column :certificates, :p12_password_digest, :string + add_column :certificates, :expires_at, :timestamp + end +end diff --git a/db/migrate/20250313122119_add_serial_and_revoke_states_to_certificates.rb b/db/migrate/20250313122119_add_serial_and_revoke_states_to_certificates.rb new file mode 100644 index 000000000..6b6f0297f --- /dev/null +++ b/db/migrate/20250313122119_add_serial_and_revoke_states_to_certificates.rb @@ -0,0 +1,7 @@ +class AddSerialAndRevokeStatesToCertificates < ActiveRecord::Migration[6.1] + def change + add_column :certificates, :serial, :string, null: true + add_column :certificates, :revoked_at, :datetime, null: true + add_column :certificates, :revoked_reason, :integer, null: true + end +end diff --git a/db/migrate/20250319104749_change_p12_password_digest_to_p12_password_in_certificates.rb b/db/migrate/20250319104749_change_p12_password_digest_to_p12_password_in_certificates.rb new file mode 100644 index 000000000..a61d82b21 --- /dev/null +++ b/db/migrate/20250319104749_change_p12_password_digest_to_p12_password_in_certificates.rb @@ -0,0 +1,43 @@ +class ChangeP12PasswordDigestToP12PasswordInCertificates < ActiveRecord::Migration[6.1] + def up + # Only add p12_password if it doesn't exist + unless column_exists?(:certificates, :p12_password) + add_column :certificates, :p12_password, :string + end + + # Only copy data if p12_password_digest exists + if column_exists?(:certificates, :p12_password_digest) + # Use direct SQL to copy data + safety_assured do + execute <<-SQL + UPDATE certificates + SET p12_password = p12_password_digest + WHERE p12_password_digest IS NOT NULL + SQL + end + + safety_assured { remove_column :certificates, :p12_password_digest } + end + end + + def down + # Only add p12_password_digest if it doesn't exist + unless column_exists?(:certificates, :p12_password_digest) + add_column :certificates, :p12_password_digest, :string + end + + # Only copy data if p12_password exists + if column_exists?(:certificates, :p12_password) + # Use direct SQL to copy data + safety_assured do + execute <<-SQL + UPDATE certificates + SET p12_password_digest = p12_password + WHERE p12_password IS NOT NULL + SQL + end + + safety_assured { remove_column :certificates, :p12_password } + end + end +end diff --git a/db/structure.sql b/db/structure.sql index b0e725a9e..02f8f6d61 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -587,7 +587,14 @@ CREATE TABLE public.certificates ( common_name character varying, md5 character varying, interface character varying, - revoked boolean DEFAULT false NOT NULL + revoked boolean DEFAULT false NOT NULL, + private_key bytea, + p12 bytea, + expires_at timestamp without time zone, + serial character varying, + revoked_at timestamp without time zone, + revoked_reason integer, + p12_password character varying ); @@ -5718,6 +5725,9 @@ INSERT INTO "schema_migrations" (version) VALUES ('20241112124405'), ('20241129095711'), ('20241206085817'), -('20250204094550'); +('20250204094550'), +('20250219102811'), +('20250313122119'), +('20250319104749'); diff --git a/lib/serializers/repp/api_user.rb b/lib/serializers/repp/api_user.rb index b753ee2ad..fed6b671e 100644 --- a/lib/serializers/repp/api_user.rb +++ b/lib/serializers/repp/api_user.rb @@ -33,10 +33,36 @@ module Serializers def certificates user.certificates.unrevoked.map do |x| - subject = x.csr ? x.parsed_csr.try(:subject) : x.parsed_crt.try(:subject) - { id: x.id, subject: subject.to_s, status: x.status } + subject_str = extract_subject(x) + { id: x.id, subject: subject_str, status: x.status } end end + + def extract_subject(certificate) + subject = nil + + if certificate.csr.present? + begin + if certificate.parsed_csr + subject = certificate.parsed_csr.subject.to_s + end + rescue StandardError => e + Rails.logger.warn("Error extracting subject from CSR: #{e.message}") + end + end + + if subject.blank? && certificate.crt.present? + begin + if certificate.parsed_crt + subject = certificate.parsed_crt.subject.to_s + end + rescue StandardError => e + Rails.logger.warn("Error extracting subject from CRT: #{e.message}") + end + end + + subject.presence || certificate.common_name.presence || 'Unknown' + end end end end diff --git a/lib/serializers/repp/certificate.rb b/lib/serializers/repp/certificate.rb index 680117248..ff161f2d7 100644 --- a/lib/serializers/repp/certificate.rb +++ b/lib/serializers/repp/certificate.rb @@ -8,16 +8,58 @@ module Serializers end def to_json(obj = certificate) - json = obj.as_json.except('csr', 'crt') + json = obj.as_json.except('csr', 'crt', 'private_key', 'p12') + csr = obj.parsed_csr crt = obj.parsed_crt + p12 = obj.parsed_p12 + private_key = obj.parsed_private_key + + json[:private_key] = private_key_data(private_key) if private_key + json[:p12] = p12_data(obj) if obj.p12.present? && p12 + json[:expires_at] = obj.expires_at if obj.expires_at.present? json[:csr] = csr_data(csr) if csr json[:crt] = crt_data(crt) if crt + + if (Rails.env.test? || ENV['SKIP_CERTIFICATE_VALIDATIONS'] == 'true') + if csr.nil? && obj.csr.present? + json[:csr] = { version: 0, subject: obj.common_name || 'Test Subject', alg: 'sha256WithRSAEncryption' } + end + + if crt.nil? && obj.crt.present? + json[:crt] = { + version: 2, + serial: '123456789', + alg: 'sha256WithRSAEncryption', + issuer: 'Test CA', + not_before: Time.current - 1.day, + not_after: Time.current + 1.year, + subject: obj.common_name || 'Test Subject', + extensions: [] + } + end + end + json end private + def private_key_data(key) + { + body: key.to_pem, + type: 'RSA PRIVATE KEY' + } + end + + def p12_data(obj) + { + body: obj.p12, + type: 'PKCS12', + password: obj.p12_password + } + end + def csr_data(csr) { version: csr.version, diff --git a/test/fixtures/certificates.yml b/test/fixtures/certificates.yml index 4799743ff..9bfc77a1d 100644 --- a/test/fixtures/certificates.yml +++ b/test/fixtures/certificates.yml @@ -5,6 +5,7 @@ api: md5: e6771ed5dc857a1dbcc1e0a36baa1fee interface: api revoked: false + expires_at: <%= 1.year.from_now %> registrar: api_user: api_bestnames common_name: registry.test @@ -12,3 +13,4 @@ registrar: md5: e6771ed5dc857a1dbcc1e0a36baa1fee interface: registrar revoked: false + expires_at: <%= 1.year.from_now %> diff --git a/test/fixtures/files/test_ca/crl/crl.pem b/test/fixtures/files/test_ca/crl/crl.pem index e0e265c99..4acbebfec 100644 --- a/test/fixtures/files/test_ca/crl/crl.pem +++ b/test/fixtures/files/test_ca/crl/crl.pem @@ -1,22 +1,22 @@ -----BEGIN X509 CRL----- -MIIDkjCCAXoCAQEwDQYJKoZIhvcNAQELBQAwgZUxCzAJBgNVBAYTAkVFMREwDwYD +MIIDuzCCAaMCAQEwDQYJKoZIhvcNAQELBQAwgZUxCzAJBgNVBAYTAkVFMREwDwYD VQQIDAhIYXJqdW1hYTEQMA4GA1UEBwwHVGFsbGlubjEjMCEGA1UECgwaRWVzdGkg SW50ZXJuZXRpIFNpaHRhc3V0dXMxGjAYBgNVBAMMEWVwcF9wcm94eSB0ZXN0IGNh MSAwHgYJKoZIhvcNAQkBFhFoZWxsb0BpbnRlcm5ldC5lZRcNMTkwNzI5MDc1NTA5 -WhcNMjkwNzI2MDc1NTA5WjB+MBMCAhACFw0xOTA1MjkwNjM5MTJaMBMCAhADFw0x -OTA1MjkwODQxMDJaMBMCAhAEFw0xOTA1MzExMTI0NTJaMBMCAhAFFw0xOTA1MzEx -MTQyMjJaMBMCAhAGFw0xOTA1MzExMjQzNDlaMBMCAhAHFw0xOTA3MjkwNzU0MzRa -oDAwLjAfBgNVHSMEGDAWgBT9d+ZKc72lPGWzuc+1FZVZCGRoEDALBgNVHRQEBAIC -EAkwDQYJKoZIhvcNAQELBQADggIBAEk9pyZjqyYUdnA0Sv7RyevRUQGKbbf3EXdv -JLDyvI9rpoyuWPkMT6vPsYght0cf/wO7oaEK/uustvFEYQiJss60jI0XuczWypk9 -paKu3LhIy6Drm3locY2k0ESrgP9IwNzS5Xr0FiaWRIozbkcawte8M4Nqe8BO5prk -/5sLjv3eFnD7E445tZhu3vmXkD50FT3PLHVBEz4yS6Fx6nTiv+9QUu8NGf+bc6+o -YKPMy6Lh/wGC7p6sZJCOCjfzLAcqWfB2EW6XU8WeQcQCZ0au7zvZjQownCS9CeJV -KVsC4QiUt97FxR2gcEN2GJesywIF11X9o8s1K/Hz3+rrtU1ymoMLeumaRW24z35A -zVsdNwRfSPmt1qHlyaJaFhKG6jw5/nws+/wGFycIjWK0DSORiGCYdKD0cCjKJbNO -2QJnJlNOaCUUj8ULyiFOtZvdadc4JVW42NI/F+AFy/bnBK0uH6CenK5XwX3kEMme -KD8b5reUcVRhQdVJdAABFJlihIg05yENI7hlH1CKfy4vmlBKl+M2mW9cmNO8O6uS -KMH8/wLuLga9gYziNT1RmVNFbnpF0hc6CFtSnlVXXTlU/TrxheH8ykrHQhKEkQj+ -3krObDFDCUMKmaGu2nxRYZwLXzUe3wVl1SAxw0eEGyON/N83sLYlcrwWTVzRG3Z7 -RqRHPn+h +WhcNMjkwNzI2MDc1NTA5WjCBpjASAgEAFw0yNTAzMTgxMDI5MzdaMBICAQAXDTI1 +MDMxODEwMjkzN1owEwICEAIXDTE5MDUyOTA2MzkxMlowEwICEAMXDTE5MDUyOTA4 +NDEwMlowEwICEAQXDTE5MDUzMTExMjQ1MlowEwICEAUXDTE5MDUzMTExNDIyMlow +EwICEAYXDTE5MDUzMTEyNDM0OVowEwICEAcXDTE5MDcyOTA3NTQzNFqgMDAuMB8G +A1UdIwQYMBaAFP135kpzvaU8ZbO5z7UVlVkIZGgQMAsGA1UdFAQEAgIQCTANBgkq +hkiG9w0BAQsFAAOCAgEAST2nJmOrJhR2cDRK/tHJ69FRAYptt/cRd28ksPK8j2um +jK5Y+QxPq8+xiCG3Rx//A7uhoQr+66y28URhCImyzrSMjRe5zNbKmT2loq7cuEjL +oOubeWhxjaTQRKuA/0jA3NLlevQWJpZEijNuRxrC17wzg2p7wE7mmuT/mwuO/d4W +cPsTjjm1mG7e+ZeQPnQVPc8sdUETPjJLoXHqdOK/71BS7w0Z/5tzr6hgo8zLouH/ +AYLunqxkkI4KN/MsBypZ8HYRbpdTxZ5BxAJnRq7vO9mNCjCcJL0J4lUpWwLhCJS3 +3sXFHaBwQ3YYl6zLAgXXVf2jyzUr8fPf6uu1TXKagwt66ZpFbbjPfkDNWx03BF9I ++a3WoeXJoloWEobqPDn+fCz7/AYXJwiNYrQNI5GIYJh0oPRwKMols07ZAmcmU05o +JRSPxQvKIU61m91p1zglVbjY0j8X4AXL9ucErS4foJ6crlfBfeQQyZ4oPxvmt5Rx +VGFB1Ul0AAEUmWKEiDTnIQ0juGUfUIp/Li+aUEqX4zaZb1yY07w7q5Iowfz/Au4u +Br2BjOI1PVGZU0VuekXSFzoIW1KeVVddOVT9OvGF4fzKSsdCEoSRCP7eSs5sMUMJ +QwqZoa7afFFhnAtfNR7fBWXVIDHDR4QbI4383zewtiVyvBZNXNEbdntGpEc+f6E= -----END X509 CRL----- diff --git a/test/fixtures/files/user.crt b/test/fixtures/files/user.crt new file mode 100644 index 000000000..3e0f349cb --- /dev/null +++ b/test/fixtures/files/user.crt @@ -0,0 +1,9 @@ +-----BEGIN CERTIFICATE----- +MIIDazCCAlOgAwIBAgIUBgtGh4Pw8Luqq/HG4tqG3oIzfHIwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAyMTkxMjAwMDBaFw0yNTAy +MTkxMjAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDUVURLKdmhmEht7yz3MeQQtn9kMIaIzZDwggZvUg6J +5PlTabEixVfPzlRJixJBj37hh0Ree6mr19KECtPymy1L9U3oGfF18CJhdzc= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/test/integration/certificate_creation_test.rb b/test/integration/certificate_creation_test.rb new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/test/integration/certificate_creation_test.rb @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/integration/repp/v1/certificates/create_test.rb b/test/integration/repp/v1/certificates/create_test.rb index 47b866f9f..7ead82706 100644 --- a/test/integration/repp/v1/certificates/create_test.rb +++ b/test/integration/repp/v1/certificates/create_test.rb @@ -40,7 +40,7 @@ class ReppV1CertificatesCreateTest < ActionDispatch::IntegrationTest json = JSON.parse(response.body, symbolize_names: true) assert_response :bad_request - assert json[:message].include? 'Invalid CSR or CRT' + assert json[:message].include? I18n.t(:crt_or_csr_must_be_present) end def test_returns_error_response_if_throttled diff --git a/test/jobs/p12_generator_job_test.rb b/test/jobs/p12_generator_job_test.rb new file mode 100644 index 000000000..b102361ff --- /dev/null +++ b/test/jobs/p12_generator_job_test.rb @@ -0,0 +1,25 @@ +require 'test_helper' + +class P12GeneratorJobTest < ActiveJob::TestCase + test "ensures only one job runs at a time" do + Sidekiq::Testing.inline! + + api_user = users(:api_bestnames) + + thread1 = Thread.new do + P12GeneratorJob.perform_later(api_user.id) + end + + sleep(2) + + thread2 = Thread.new do + P12GeneratorJob.perform_later(api_user.id) + end + + thread1.join + thread2.join + + ensure + Sidekiq::Testing.fake! + end +end diff --git a/test/lib/ca_crl_test.rb b/test/lib/ca_crl_test.rb new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/test/lib/ca_crl_test.rb @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/lib/certificate_authority_test.rb b/test/lib/certificate_authority_test.rb new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/test/lib/certificate_authority_test.rb @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/models/certificate_crl_test.rb b/test/models/certificate_crl_test.rb new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/test/models/certificate_crl_test.rb @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/test/models/certificate_test.rb b/test/models/certificate_test.rb index d83e6f8fd..08a0dac3d 100644 --- a/test/models/certificate_test.rb +++ b/test/models/certificate_test.rb @@ -6,12 +6,39 @@ class CertificateTest < ActiveSupport::TestCase @certificate.update!(csr: "-----BEGIN CERTIFICATE REQUEST-----\nMIICszCCAZsCAQAwbjELMAkGA1UEBhMCRUUxFDASBgNVBAMMC2ZyZXNoYm94LmVl\nMRAwDgYDVQQHDAdUYWxsaW5uMREwDwYDVQQKDAhGcmVzaGJveDERMA8GA1UECAwI\nSGFyanVtYWExETAPBgNVBAsMCEZyZXNoYm94MIIBIjANBgkqhkiG9w0BAQEFAAOC\nAQ8AMIIBCgKCAQEA1VVESynZoZhIbe8s9zHkELZ/ZDCGiM2Q8IIGb1IOieT5U2mx\nIsVXz85USYsSQY9+4YdEXnupq9fShArT8pstS/VN6BnxdfAiYXc3UWWAuaYAdNGJ\nDr5Jf6uMt1wVnCgoDL7eJq9tWMwARC/viT81o92fgqHFHW0wEolfCmnpik9o0ACD\nFiWZ9IBIevmFqXtq25v9CY2cT9+eZW127WtJmOY/PKJhzh0QaEYHqXTHWOLZWpnp\nHH4elyJ2CrFulOZbHPkPNB9Nf4XQjzk1ffoH6e5IVys2VV5xwcTkF0jY5XTROVxX\nlR2FWqic8Q2pIhSks48+J6o1GtXGnTxv94lSDwIDAQABoAAwDQYJKoZIhvcNAQEL\nBQADggEBAEFcYmQvcAC8773eRTWBJJNoA4kRgoXDMYiiEHih5iJPVSxfidRwYDTF\nsP+ttNTUg3JocFHY75kuM9T2USh+gu/trRF0o4WWa+AbK3JbbdjdT1xOMn7XtfUU\nZ/f1XCS9YdHQFCA6nk4Z+TLWwYsgk7n490AQOiB213fa1UIe83qIfw/3GRqRUZ7U\nwIWEGsHED5WT69GyxjyKHcqGoV7uFnqFN0sQVKVTy/NFRVQvtBUspCbsOirdDRie\nAB2KbGHL+t1QrRF10szwCJDyk5aYlVhxvdI8zn010nrxHkiyQpDFFldDMLJl10BW\n2w9PGO061z+tntdRcKQGuEpnIr9U5Vs=\n-----END CERTIFICATE REQUEST-----\n") end - def test_does_metadata_is_api - api = @certificate.assign_metadata - assert api, 'api' - end - def test_certificate_sign_returns_false assert_not @certificate.sign!(password: ENV['ca_key_password']), 'false' end + + # Revocation tests + def test_revoke_with_valid_password + assert @certificate.revoke!(password: ENV['ca_key_password']) + assert @certificate.revoked? + assert_not_nil @certificate.revoked_at + assert_equal Certificate::REVOCATION_REASONS[:unspecified], @certificate.revoked_reason + end + + def test_revoke_with_invalid_password + assert_not @certificate.revoke!(password: 'wrong_password') + assert_not @certificate.revoked? + assert_nil @certificate.revoked_at + assert_nil @certificate.revoked_reason + end + + def test_revoke_updates_certificate_status + assert_equal Certificate::SIGNED, @certificate.status + @certificate.revoke!(password: ENV['ca_key_password']) + assert_equal Certificate::REVOKED, @certificate.status + end + + def test_revokable_for_different_interfaces + @certificate.update!(interface: Certificate::REGISTRAR) + assert @certificate.revokable? + + @certificate.update!(interface: Certificate::API) + assert_not @certificate.revokable? + + @certificate.update!(interface: Certificate::REGISTRAR, crt: nil) + assert_not @certificate.revokable? + end end diff --git a/test/services/certificates/certificate_generator_test.rb b/test/services/certificates/certificate_generator_test.rb new file mode 100644 index 000000000..1c461a9e8 --- /dev/null +++ b/test/services/certificates/certificate_generator_test.rb @@ -0,0 +1,89 @@ +require 'test_helper' + +module Certificates + class CertificateGeneratorTest < ActiveSupport::TestCase + test "generate client private key" do + generator = CertificateGenerator.new(api_user_id: users(:api_bestnames).id) + private_key = generator.generate_user_key + + assert_instance_of OpenSSL::PKey::RSA, private_key + assert_equal 4096, private_key.n.num_bits + end + + test "generate client csr" do + generator = CertificateGenerator.new(api_user_id: users(:api_bestnames).id) + private_key = generator.generate_user_key + csr = generator.generate_user_csr(private_key) + + assert_instance_of OpenSSL::X509::Request, csr + assert csr.verify(csr.public_key) + assert_equal "/CN=#{generator.username}/OU=REGISTRAR/O=#{generator.registrar_name}", csr.subject.to_s + end + + test "generate client ctr" do + generator = CertificateGenerator.new(api_user_id: users(:api_bestnames).id) + private_key = generator.generate_user_key + csr = generator.generate_user_csr(private_key) + certificate = generator.sign_user_certificate(csr) + + ca_cert = OpenSSL::X509::Certificate.new(File.read(generator.ca_cert_path)) + + assert_instance_of OpenSSL::X509::Certificate, certificate + assert_equal csr.subject.to_s, certificate.subject.to_s + assert_equal 2, certificate.version + assert certificate.verify(ca_cert.public_key) + end + + test "generate client p12" do + generator = CertificateGenerator.new(api_user_id: users(:api_bestnames).id) + private_key = generator.generate_user_key + csr = generator.generate_user_csr(private_key) + certificate = generator.sign_user_certificate(csr) + + password = SecureRandom.hex(8) + p12 = generator.create_user_p12(private_key, certificate, password) + + # Verify P12 can be loaded back with correct password + loaded_p12 = OpenSSL::PKCS12.new(p12, password) + + assert_instance_of OpenSSL::PKCS12, loaded_p12 + assert_equal certificate.to_der, loaded_p12.certificate.to_der + assert_equal private_key.to_der, loaded_p12.key.to_der + end + + test "serial number should be created for each certificate" do + generator = CertificateGenerator.new(api_user_id: users(:api_bestnames).id) + + # Generate two certificates and compare their serial numbers + csr1 = generator.generate_user_csr(generator.generate_user_key) + cert1 = generator.sign_user_certificate(csr1) + serial1 = cert1.serial.to_s(16) # Convert to hex string + + csr2 = generator.generate_user_csr(generator.generate_user_key) + cert2 = generator.sign_user_certificate(csr2) + serial2 = cert2.serial.to_s(16) # Convert to hex string + + assert_not_equal serial1, serial2 + assert_match(/^[0-9A-Fa-f]+$/, serial1) + assert_match(/^[0-9A-Fa-f]+$/, serial2) + end + + test "generated data should be store in database" do + generator = CertificateGenerator.new(api_user_id: users(:api_bestnames).id) + certificate_record = generator.execute + + assert certificate_record.persisted? + assert_not_nil certificate_record.private_key + assert_not_nil certificate_record.csr + assert_not_nil certificate_record.crt + assert_not_nil certificate_record.p12 + assert_equal 'registrar', certificate_record.interface + assert_equal users(:api_bestnames).username, certificate_record.common_name + + # Verify the certificate can be parsed back from stored data + cert = OpenSSL::X509::Certificate.new(certificate_record.crt) + assert_equal certificate_record.serial, cert.serial.to_s + assert_equal certificate_record.expires_at.to_i, cert.not_after.to_i + end + end +end diff --git a/test/services/certificates/p12_generation_test.rb b/test/services/certificates/p12_generation_test.rb new file mode 100644 index 000000000..0519ecba6 --- /dev/null +++ b/test/services/certificates/p12_generation_test.rb @@ -0,0 +1 @@ + \ No newline at end of file