Merge pull request #2756 from internetee/registrar-p12-generator

Add UserCertificate model with tests
This commit is contained in:
Timo Võhmar 2025-04-25 09:14:53 +03:00 committed by GitHub
commit f64575d2b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 654 additions and 76 deletions

7
.gitignore vendored
View file

@ -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/

View file

@ -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'

View file

@ -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)

View 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

View file

@ -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

View 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

View file

@ -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 = '')

View file

@ -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?

View 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

View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
module Types
include Dry.Types()
end

View 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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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');

View file

@ -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

View file

@ -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,

View file

@ -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 %>

View file

@ -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
View file

@ -0,0 +1,9 @@
-----BEGIN CERTIFICATE-----
MIIDazCCAlOgAwIBAgIUBgtGh4Pw8Luqq/HG4tqG3oIzfHIwDQYJKoZIhvcNAQEL
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDAyMTkxMjAwMDBaFw0yNTAy
MTkxMjAwMDBaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
AQUAA4IBDwAwggEKAoIBAQDUVURLKdmhmEht7yz3MeQQtn9kMIaIzZDwggZvUg6J
5PlTabEixVfPzlRJixJBj37hh0Ree6mr19KECtPymy1L9U3oGfF18CJhdzc=
-----END CERTIFICATE-----

View file

@ -0,0 +1 @@

View file

@ -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

View 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
View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

View file

@ -0,0 +1 @@

View file

@ -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

View 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

View file

@ -0,0 +1 @@