diff --git a/.gitignore b/.gitignore index 3bbcf45a5..edf52357d 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,4 @@ ettevotja_rekvisiidid__lihtandmed.csv.zip Dockerfile.dev .cursor/ .cursorrules - -# Ignore sensitive certificates /certs/ 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/models/api_user.rb b/app/models/api_user.rb index d6572999e..abb266b4e 100644 --- a/app/models/api_user.rb +++ b/app/models/api_user.rb @@ -20,6 +20,7 @@ class ApiUser < User # TODO: should have max request limit per day? belongs_to :registrar has_many :certificates + has_many :user_certificates validates :username, :plain_text_password, :registrar, :roles, presence: true validates :plain_text_password, length: { minimum: min_password_length } diff --git a/app/models/user_certificate.rb b/app/models/user_certificate.rb new file mode 100644 index 000000000..0e0496cd4 --- /dev/null +++ b/app/models/user_certificate.rb @@ -0,0 +1,54 @@ +class UserCertificate < ApplicationRecord + belongs_to :user + + validates :user, presence: true + validates :private_key, presence: true + + enum status: { + pending: 'pending', + active: 'active', + revoked: 'revoked' + } + + def renewable? + return false unless certificate.present? + return false if revoked? + + expires_at.present? && expires_at < 30.days.from_now + end + + def expired? + return false unless certificate.present? + return false if revoked? + + expires_at.present? && expires_at < Time.current + end + + def renew + raise "Certificate cannot be renewed" unless renewable? + + generator = Certificates::CertificateGenerator.new( + username: user.username, + registrar_code: user.registrar_code, + registrar_name: user.registrar_name, + user_certificate: self + ) + + generator.renew_certificate + end + + def self.generate_certificates_for_api_user(api_user:) + cert = UserCertificate.create!( + user: api_user, + status: 'pending', + private_key: '' + ) + + Certificates::CertificateGenerator.new( + username: api_user.username, + registrar_code: api_user.registrar_code, + registrar_name: api_user.registrar_name, + user_certificate: cert + ).call + end +end diff --git a/app/services/certificates/certificate_generator.rb b/app/services/certificates/certificate_generator.rb new file mode 100644 index 000000000..9425684d3 --- /dev/null +++ b/app/services/certificates/certificate_generator.rb @@ -0,0 +1,158 @@ +module Certificates + class CertificateGenerator < Dry::Struct + attribute :username, Types::Strict::String + attribute :registrar_code, Types::Coercible::String + attribute :registrar_name, Types::Strict::String + attribute :user_certificate, Types.Instance(UserCertificate) + + CERTS_PATH = Rails.root.join('certs') + CA_PATH = CERTS_PATH.join('ca') + + # User certificate files + USER_CSR_NAME = 'user.csr' + USER_KEY_NAME = 'user.key' + USER_CRT_NAME = 'user.crt' + USER_P12_NAME = 'user.p12' + + # CA files + CA_CERT_PATH = CA_PATH.join('certs/ca.crt.pem') + CA_KEY_PATH = CA_PATH.join('private/ca.key.pem') + CA_PASSWORD = '123456' + + def initialize(*) + super + ensure_directories_exist + end + + def call + csr, key = generate_csr_and_key + certificate = sign_certificate(csr) + create_p12(key, certificate) + end + + def generate_csr_and_key + key = OpenSSL::PKey::RSA.new(4096) + encrypted_key = key.export(OpenSSL::Cipher.new('AES-256-CBC'), CA_PASSWORD) + save_private_key(encrypted_key) + + request = OpenSSL::X509::Request.new + request.version = 0 + request.subject = OpenSSL::X509::Name.new([ + ['CN', username, OpenSSL::ASN1::UTF8STRING], + ['OU', registrar_code, OpenSSL::ASN1::UTF8STRING], + ['O', registrar_name, OpenSSL::ASN1::UTF8STRING] + ]) + + request.public_key = key.public_key + request.sign(key, OpenSSL::Digest::SHA256.new) + + save_csr(request) + + user_certificate&.update!( + private_key: encrypted_key, + csr: request.to_pem, + status: 'pending' + ) + + [request, key] + end + + def sign_certificate(csr) + ca_key = OpenSSL::PKey::RSA.new(File.read(CA_KEY_PATH), CA_PASSWORD) + ca_cert = OpenSSL::X509::Certificate.new(File.read(CA_CERT_PATH)) + + cert = OpenSSL::X509::Certificate.new + cert.serial = 0 + cert.version = 2 + cert.not_before = Time.now + cert.not_after = Time.now + 365 * 24 * 60 * 60 # 1 year + + 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) + + save_certificate(cert) + + user_certificate&.update!( + certificate: cert.to_pem, + status: 'active', + expires_at: cert.not_after + ) + + cert + end + + def create_p12(key, certificate, password = nil) + ca_cert = OpenSSL::X509::Certificate.new(File.read(CA_CERT_PATH)) + + p12 = OpenSSL::PKCS12.create( + password, # P12 password (optional) + username, # Friendly name + key, # User's private key + certificate, # User's certificate + [ca_cert], # Chain of certificates + ) + + File.open(CERTS_PATH.join(USER_P12_NAME), 'wb') do |file| + file.write(p12.to_der) + end + + user_certificate&.update!( + p12: p12.to_der, + p12_password_digest: password ? BCrypt::Password.create(password) : nil + ) + + p12 + end + + def renew_certificate + raise "Certificate not found" unless user_certificate&.certificate.present? + raise "Cannot renew revoked certificate" if user_certificate.revoked? + + # Используем существующий CSR + csr = OpenSSL::X509::Request.new(user_certificate.csr) + + # Создаем новый сертификат + certificate = sign_certificate(csr) + + # Создаем новый P12 с существующим ключом + key = OpenSSL::PKey::RSA.new(user_certificate.private_key, CA_PASSWORD) + create_p12(key, certificate) + end + + private + + def ensure_directories_exist + FileUtils.mkdir_p(CERTS_PATH) + FileUtils.mkdir_p(CA_PATH.join('certs')) + FileUtils.mkdir_p(CA_PATH.join('private')) + + # Set proper permissions for private directory + FileUtils.chmod(0700, CA_PATH.join('private')) + end + + def save_private_key(encrypted_key) + path = CERTS_PATH.join(USER_KEY_NAME) + File.write(path, encrypted_key) + FileUtils.chmod(0600, path) + end + + def save_csr(request) + File.write(CERTS_PATH.join(USER_CSR_NAME), request.to_pem) + end + + def save_certificate(certificate) + File.write(CERTS_PATH.join(USER_CRT_NAME), certificate.to_pem) + 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/db/migrate/20250218115707_create_user_certificates.rb b/db/migrate/20250218115707_create_user_certificates.rb new file mode 100644 index 000000000..054503598 --- /dev/null +++ b/db/migrate/20250218115707_create_user_certificates.rb @@ -0,0 +1,19 @@ +class CreateUserCertificates < ActiveRecord::Migration[6.1] + def change + create_table :user_certificates do |t| + t.references :user, null: false, foreign_key: true + t.binary :private_key, null: false + t.text :csr + t.text :certificate + t.binary :p12 + t.string :status + t.datetime :expires_at + t.datetime :revoked_at + t.string :p12_password_digest + + t.timestamps + end + + add_index :user_certificates, [:user_id, :status] + end +end diff --git a/db/structure.sql b/db/structure.sql index b0e725a9e..21c916ad1 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -2812,6 +2812,45 @@ CREATE SEQUENCE public.settings_id_seq ALTER SEQUENCE public.settings_id_seq OWNED BY public.settings.id; +-- +-- Name: user_certificates; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_certificates ( + id bigint NOT NULL, + user_id bigint NOT NULL, + private_key bytea NOT NULL, + csr text, + certificate text, + p12 bytea, + status character varying, + expires_at timestamp without time zone, + revoked_at timestamp without time zone, + p12_password_digest character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: user_certificates_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.user_certificates_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: user_certificates_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.user_certificates_id_seq OWNED BY public.user_certificates.id; + + -- -- Name: users; Type: TABLE; Schema: public; Owner: - -- @@ -3512,6 +3551,13 @@ ALTER TABLE ONLY public.setting_entries ALTER COLUMN id SET DEFAULT nextval('pub ALTER TABLE ONLY public.settings ALTER COLUMN id SET DEFAULT nextval('public.settings_id_seq'::regclass); +-- +-- Name: user_certificates id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_certificates ALTER COLUMN id SET DEFAULT nextval('public.user_certificates_id_seq'::regclass); + + -- -- Name: users id; Type: DEFAULT; Schema: public; Owner: - -- @@ -4210,6 +4256,14 @@ ALTER TABLE ONLY public.zones ADD CONSTRAINT unique_zone_origin UNIQUE (origin); +-- +-- Name: user_certificates user_certificates_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_certificates + ADD CONSTRAINT user_certificates_pkey PRIMARY KEY (id); + + -- -- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4895,6 +4949,20 @@ CREATE UNIQUE INDEX index_setting_entries_on_code ON public.setting_entries USIN CREATE UNIQUE INDEX index_settings_on_thing_type_and_thing_id_and_var ON public.settings USING btree (thing_type, thing_id, var); +-- +-- Name: index_user_certificates_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_certificates_on_user_id ON public.user_certificates USING btree (user_id); + + +-- +-- Name: index_user_certificates_on_user_id_and_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_user_certificates_on_user_id_and_status ON public.user_certificates USING btree (user_id, status); + + -- -- Name: index_users_on_identity_code; Type: INDEX; Schema: public; Owner: - -- @@ -5040,6 +5108,14 @@ ALTER TABLE ONLY public.domains ADD CONSTRAINT domains_registrar_id_fk FOREIGN KEY (registrar_id) REFERENCES public.registrars(id); +-- +-- Name: user_certificates fk_rails_03b0a0c9d8; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_certificates + ADD CONSTRAINT fk_rails_03b0a0c9d8 FOREIGN KEY (user_id) REFERENCES public.users(id); + + -- -- Name: invoices fk_rails_242b91538b; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -5718,6 +5794,7 @@ INSERT INTO "schema_migrations" (version) VALUES ('20241112124405'), ('20241129095711'), ('20241206085817'), -('20250204094550'); +('20250204094550'), +('20250218115707'); diff --git a/test/fixtures/user_certificates.yml b/test/fixtures/user_certificates.yml new file mode 100644 index 000000000..ef90cf4be --- /dev/null +++ b/test/fixtures/user_certificates.yml @@ -0,0 +1,7 @@ +one: + user: api_bestnames + private_key: "encrypted_private_key_data" + csr: "dummy_csr_data" + certificate: "dummy_certificate_data" + status: "active" + expires_at: <%= 60.days.from_now %> diff --git a/test/models/user_certificate_test.rb b/test/models/user_certificate_test.rb new file mode 100644 index 000000000..41fe7095c --- /dev/null +++ b/test/models/user_certificate_test.rb @@ -0,0 +1,52 @@ +require 'test_helper' + +class UserCertificateTest < ActiveSupport::TestCase + def setup + @user = users(:api_bestnames) + @certificate = user_certificates(:one) + end + + test "should be valid with required attributes" do + certificate = UserCertificate.new( + user: @user, + private_key: 'dummy_key', + status: 'pending' + ) + assert certificate.valid? + end + + test "should not be valid without user" do + @certificate.user = nil + assert_not @certificate.valid? + end + + test "should not be valid without private_key" do + @certificate.private_key = nil + assert_not @certificate.valid? + end + + test "renewable? should be false without certificate" do + @certificate.certificate = nil + assert_not @certificate.renewable? + end + + test "renewable? should be false when revoked" do + @certificate.status = 'revoked' + assert_not @certificate.renewable? + end + + test "renewable? should be true when expires in less than 30 days" do + @certificate.expires_at = 29.days.from_now + assert @certificate.renewable? + end + + test "expired? should be true when certificate is expired" do + @certificate.expires_at = 1.day.ago + assert @certificate.expired? + end + + test "renew should raise error when certificate is not renewable" do + @certificate.status = 'revoked' + assert_raises(RuntimeError) { @certificate.renew } + end +end