mirror of
https://github.com/internetee/registry.git
synced 2025-07-30 06:26:15 +02:00
Merge pull request #2756 from internetee/registrar-p12-generator
Add UserCertificate model with tests
This commit is contained in:
commit
f64575d2b9
31 changed files with 654 additions and 76 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
@ -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/
|
3
Gemfile
3
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'
|
||||
|
|
26
Gemfile.lock
26
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)
|
||||
|
|
28
app/controllers/repp/v1/certificates/p12_controller.rb
Normal file
28
app/controllers/repp/v1/certificates/p12_controller.rb
Normal file
|
@ -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
|
|
@ -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
|
||||
|
|
17
app/jobs/p12_generator_job.rb
Normal file
17
app/jobs/p12_generator_job.rb
Normal file
|
@ -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
|
|
@ -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 = '')
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
146
app/services/certificates/certificate_generator.rb
Normal file
146
app/services/certificates/certificate_generator.rb
Normal file
|
@ -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
|
5
config/initializers/dry_types.rb
Normal file
5
config/initializers/dry_types.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
include Dry.Types()
|
||||
end
|
5
config/initializers/file_exists_alias.rb
Normal file
5
config/initializers/file_exists_alias.rb
Normal file
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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');
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
2
test/fixtures/certificates.yml
vendored
2
test/fixtures/certificates.yml
vendored
|
@ -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 %>
|
||||
|
|
34
test/fixtures/files/test_ca/crl/crl.pem
vendored
34
test/fixtures/files/test_ca/crl/crl.pem
vendored
|
@ -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-----
|
||||
|
|
9
test/fixtures/files/user.crt
vendored
Normal file
9
test/fixtures/files/user.crt
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIDazCCAlOgAwIBAgIUBgtGh4Pw8Luqq/HG4tqG3oIzfHIwDQYJKoZIhvcNAQEL
|
||||
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAyMTkxMjAwMDBaFw0yNTAy
|
||||
MTkxMjAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
|
||||
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
|
||||
AQUAA4IBDwAwggEKAoIBAQDUVURLKdmhmEht7yz3MeQQtn9kMIaIzZDwggZvUg6J
|
||||
5PlTabEixVfPzlRJixJBj37hh0Ree6mr19KECtPymy1L9U3oGfF18CJhdzc=
|
||||
-----END CERTIFICATE-----
|
1
test/integration/certificate_creation_test.rb
Normal file
1
test/integration/certificate_creation_test.rb
Normal file
|
@ -0,0 +1 @@
|
|||
|
|
@ -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
|
||||
|
|
25
test/jobs/p12_generator_job_test.rb
Normal file
25
test/jobs/p12_generator_job_test.rb
Normal file
|
@ -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
|
1
test/lib/ca_crl_test.rb
Normal file
1
test/lib/ca_crl_test.rb
Normal file
|
@ -0,0 +1 @@
|
|||
|
1
test/lib/certificate_authority_test.rb
Normal file
1
test/lib/certificate_authority_test.rb
Normal file
|
@ -0,0 +1 @@
|
|||
|
1
test/models/certificate_crl_test.rb
Normal file
1
test/models/certificate_crl_test.rb
Normal file
|
@ -0,0 +1 @@
|
|||
|
|
@ -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
|
||||
|
|
89
test/services/certificates/certificate_generator_test.rb
Normal file
89
test/services/certificates/certificate_generator_test.rb
Normal file
|
@ -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
|
1
test/services/certificates/p12_generation_test.rb
Normal file
1
test/services/certificates/p12_generation_test.rb
Normal file
|
@ -0,0 +1 @@
|
|||
|
Loading…
Add table
Add a link
Reference in a new issue