diff --git a/.gitignore b/.gitignore index cb718a6939f2cb8208a412192a8886f03396ed1a..104c6930050683f5fe0f178e6370e1fc542eb99d 100644 --- a/.gitignore +++ b/.gitignore @@ -75,6 +75,8 @@ eslint-report.html /.rspec /plugins/* /.gitlab_pages_secret +/.gitlab_smime_key +/.gitlab_smime_cert package-lock.json /junit_*.xml /coverage-frontend/ diff --git a/changelogs/unreleased/feat-smime-signed-notification-emails.yml b/changelogs/unreleased/feat-smime-signed-notification-emails.yml new file mode 100644 index 0000000000000000000000000000000000000000..9672d0d964cb51f452a52614d6ca03a6551486cd --- /dev/null +++ b/changelogs/unreleased/feat-smime-signed-notification-emails.yml @@ -0,0 +1,5 @@ +--- +title: Notification emails can be signed with SMIME +merge_request: 30644 +author: Diego Louzán +type: added diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 226f2ec3722bef2181295f677e4de7a86ae98b3c..2f6658594cc34cb2617b2e66d9ee5fbbfe33655b 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -95,6 +95,15 @@ production: &base email_display_name: GitLab email_reply_to: noreply@example.com email_subject_suffix: '' + email_smime: + # Uncomment and set to true if you need to enable email S/MIME signing (default: false) + # enabled: false + # S/MIME private key file in PEM format, unencrypted + # Default is '.gitlab_smime_key' relative to Rails.root (i.e. root of the GitLab app). + # key_file: /home/git/gitlab/.gitlab_smime_key + # S/MIME public certificate key in PEM format, will be attached to signed messages + # Default is '.gitlab_smime_cert' relative to Rails.root (i.e. root of the GitLab app). + # cert_file: /home/git/gitlab/.gitlab_smime_cert # Email server smtp settings are in config/initializers/smtp_settings.rb.sample diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 828732126b60c0495a48552e345281b15ad1c07f..fdc6b0a05ab40b78884ec32ad39e6c3395af0472 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -1,5 +1,6 @@ require_relative '../settings' require_relative '../object_store_settings' +require_relative '../smime_signature_settings' # Default settings Settings['ldap'] ||= Settingslogic.new({}) @@ -171,6 +172,7 @@ Settings.gitlab['email_from'] ||= ENV['GITLAB_EMAIL_FROM'] || "gitlab@#{Settings Settings.gitlab['email_display_name'] ||= ENV['GITLAB_EMAIL_DISPLAY_NAME'] || 'GitLab' Settings.gitlab['email_reply_to'] ||= ENV['GITLAB_EMAIL_REPLY_TO'] || "noreply@#{Settings.gitlab.host}" Settings.gitlab['email_subject_suffix'] ||= ENV['GITLAB_EMAIL_SUBJECT_SUFFIX'] || "" +Settings.gitlab['email_smime'] = SmimeSignatureSettings.parse(Settings.gitlab['email_smime']) Settings.gitlab['base_url'] ||= Settings.__send__(:build_base_gitlab_url) Settings.gitlab['url'] ||= Settings.__send__(:build_gitlab_url) Settings.gitlab['user'] ||= 'git' diff --git a/config/initializers/action_mailer_hooks.rb b/config/initializers/action_mailer_hooks.rb index f1b3c1f8ae8c0468dc3dfa33391ee4f6aa1fc0b6..02ca6ef13bfb0459a87d4599ecc16efcebed4a41 100644 --- a/config/initializers/action_mailer_hooks.rb +++ b/config/initializers/action_mailer_hooks.rb @@ -10,3 +10,8 @@ ActionMailer::Base.register_interceptors( ) ActionMailer::Base.register_observer(::Gitlab::Email::Hook::DeliveryMetricsObserver) + +if Gitlab.config.gitlab.email_enabled && Gitlab.config.gitlab.email_smime.enabled + ActionMailer::Base.register_interceptor(::Gitlab::Email::Hook::SmimeSignatureInterceptor) + Gitlab::AppLogger.debug "S/MIME signing of outgoing emails enabled" +end diff --git a/config/smime_signature_settings.rb b/config/smime_signature_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..3d19db84c191e186a821f28a750654ba42267e7a --- /dev/null +++ b/config/smime_signature_settings.rb @@ -0,0 +1,11 @@ +# Set default values for email_smime settings +class SmimeSignatureSettings + def self.parse(email_smime) + email_smime ||= Settingslogic.new({}) + email_smime['enabled'] = false unless email_smime['enabled'] + email_smime['key_file'] ||= Rails.root.join('.gitlab_smime_key') + email_smime['cert_file'] ||= Rails.root.join('.gitlab_smime_cert') + + email_smime + end +end diff --git a/doc/administration/index.md b/doc/administration/index.md index f7f9d753e581626bc4d5da0a68f37cccbe0db55e..6aedd3901256a6d57e6393fab67372a078b17b5c 100644 --- a/doc/administration/index.md +++ b/doc/administration/index.md @@ -64,6 +64,7 @@ Learn how to install, configure, update, and maintain your GitLab instance. - [External Classification Policy Authorization](../user/admin_area/settings/external_authorization.md) **(PREMIUM ONLY)** - [Upload a license](../user/admin_area/license.md): Upload a license to unlock features that are in paid tiers of GitLab. **(STARTER ONLY)** - [Admin Area](../user/admin_area/index.md): for self-managed instance-wide configuration and maintenance. +- [S/MIME Signing](smime_signing_email.md): how to sign all outgoing notification emails with S/MIME #### Customizing GitLab's appearance diff --git a/doc/administration/smime_signing_email.md b/doc/administration/smime_signing_email.md new file mode 100644 index 0000000000000000000000000000000000000000..9f719088f25bc604f4ee049c555f882f7bb35505 --- /dev/null +++ b/doc/administration/smime_signing_email.md @@ -0,0 +1,49 @@ +# Signing outgoing email with S/MIME + +Notification emails sent by Gitlab can be signed with S/MIME for improved +security. + +> **Note:** +Please be aware that S/MIME certificates and TLS/SSL certificates are not the +same and are used for different purposes: TLS creates a secure channel, whereas +S/MIME signs and/or encrypts the message itself + +## Enable S/MIME signing + +This setting must be explicitly enabled and a single pair of key and certificate +files must be provided in `gitlab.rb` or `gitlab.yml` if you are using Omnibus +GitLab or installed GitLab from source respectively: + +```yaml +email_smime: + enabled: true + key_file: /etc/pki/smime/private/gitlab.key + cert_file: /etc/pki/smime/certs/gitlab.crt +``` + +- Both files must be provided PEM-encoded. +- The key file must be unencrypted so that Gitlab can read it without user + intervention. + +NOTE: **Note:** Be mindful of the access levels for your private keys and visibility to +third parties. + +### How to convert S/MIME PKCS#12 / PFX format to PEM encoding + +Typically S/MIME certificates are handled in binary PKCS#12 format (`.pfx` or `.p12` +extensions), which contain the following in a single encrypted file: + +- Server certificate +- Intermediate certificates (if any) +- Private key + +In order to export the required files in PEM encoding from the PKCS#12 file, +the `openssl` command can be used: + +```bash +#-- Extract private key in PEM encoding (no password, unencrypted) +$ openssl pkcs12 -in gitlab.p12 -nocerts -nodes -out gitlab.key + +#-- Extract certificates in PEM encoding (full certs chain including CA) +$ openssl pkcs12 -in gitlab.p12 -nokeys -out gitlab.crt +``` diff --git a/doc/development/emails.md b/doc/development/emails.md index e6af075a2821a358b61e8604acba850c85d5cc9e..edec0f869898f98341d41747706be24151fe64c7 100644 --- a/doc/development/emails.md +++ b/doc/development/emails.md @@ -5,6 +5,10 @@ To view rendered emails "sent" in your development instance, visit [`/rails/letter_opener`](http://localhost:3000/rails/letter_opener). +Please note that [S/MIME signed](../administration/smime_signing_email.md) emails +[cannot be currently previewed](https://github.com/fgrehm/letter_opener_web/issues/96) with +`letter_opener`. + ## Mailer previews Rails provides a way to preview our mailer templates in HTML and plaintext using diff --git a/lib/gitlab/email/hook/smime_signature_interceptor.rb b/lib/gitlab/email/hook/smime_signature_interceptor.rb new file mode 100644 index 0000000000000000000000000000000000000000..e48041d921823f4acd32585ef71067461c33a619 --- /dev/null +++ b/lib/gitlab/email/hook/smime_signature_interceptor.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Gitlab + module Email + module Hook + class SmimeSignatureInterceptor + # Sign emails with SMIME if enabled + class << self + def delivering_email(message) + signed_message = Gitlab::Email::Smime::Signer.sign( + cert: certificate.cert, + key: certificate.key, + data: message.encoded) + signed_email = Mail.new(signed_message) + + overwrite_body(message, signed_email) + overwrite_headers(message, signed_email) + end + + private + + def certificate + @certificate ||= Gitlab::Email::Smime::Certificate.from_files(key_path, cert_path) + end + + def key_path + Gitlab.config.gitlab.email_smime.key_file + end + + def cert_path + Gitlab.config.gitlab.email_smime.cert_file + end + + def overwrite_body(message, signed_email) + # since this is a multipart email, assignment to nil is important, + # otherwise Message#body will add a new mail part + message.body = nil + message.body = signed_email.body.encoded + end + + def overwrite_headers(message, signed_email) + message.content_disposition = signed_email.content_disposition + message.content_transfer_encoding = signed_email.content_transfer_encoding + message.content_type = signed_email.content_type + end + end + end + end + end +end diff --git a/lib/gitlab/email/smime/certificate.rb b/lib/gitlab/email/smime/certificate.rb new file mode 100644 index 0000000000000000000000000000000000000000..b331c4ca19c6fe473d3b47697fb84ea056b77d7c --- /dev/null +++ b/lib/gitlab/email/smime/certificate.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Gitlab + module Email + module Smime + class Certificate + include OpenSSL + + attr_reader :key, :cert + + def key_string + @key.to_s + end + + def cert_string + @cert.to_pem + end + + def self.from_strings(key_string, cert_string) + key = PKey::RSA.new(key_string) + cert = X509::Certificate.new(cert_string) + new(key, cert) + end + + def self.from_files(key_path, cert_path) + from_strings(File.read(key_path), File.read(cert_path)) + end + + def initialize(key, cert) + @key = key + @cert = cert + end + end + end + end +end diff --git a/lib/gitlab/email/smime/signer.rb b/lib/gitlab/email/smime/signer.rb new file mode 100644 index 0000000000000000000000000000000000000000..2fa8301400301696240f64ac56ecb02ea7d76b12 --- /dev/null +++ b/lib/gitlab/email/smime/signer.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require 'openssl' + +module Gitlab + module Email + module Smime + # Tooling for signing and verifying data with SMIME + class Signer + include OpenSSL + + def self.sign(cert:, key:, data:) + signed_data = PKCS7.sign(cert, key, data, nil, PKCS7::DETACHED) + PKCS7.write_smime(signed_data) + end + + # return nil if data cannot be verified, otherwise the signed content data + def self.verify_signature(cert:, ca_cert: nil, signed_data:) + store = X509::Store.new + store.set_default_paths + store.add_cert(ca_cert) if ca_cert + + signed_smime = PKCS7.read_smime(signed_data) + signed_smime if signed_smime.verify([cert], store) + end + end + end + end +end diff --git a/spec/config/smime_signature_settings_spec.rb b/spec/config/smime_signature_settings_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..4f0c227d866befce1d398e909037ebe66aa7c6b0 --- /dev/null +++ b/spec/config/smime_signature_settings_spec.rb @@ -0,0 +1,56 @@ +require 'fast_spec_helper' + +describe SmimeSignatureSettings do + describe '.parse' do + let(:default_smime_key) { Rails.root.join('.gitlab_smime_key') } + let(:default_smime_cert) { Rails.root.join('.gitlab_smime_cert') } + + it 'sets correct default values to disabled' do + parsed_settings = described_class.parse(nil) + + expect(parsed_settings['enabled']).to be(false) + expect(parsed_settings['key_file']).to eq(default_smime_key) + expect(parsed_settings['cert_file']).to eq(default_smime_cert) + end + + context 'when providing custom values' do + it 'sets correct default values to disabled' do + custom_settings = Settingslogic.new({}) + + parsed_settings = described_class.parse(custom_settings) + + expect(parsed_settings['enabled']).to be(false) + expect(parsed_settings['key_file']).to eq(default_smime_key) + expect(parsed_settings['cert_file']).to eq(default_smime_cert) + end + + it 'enables smime with default key and cert' do + custom_settings = Settingslogic.new({ + 'enabled' => true + }) + + parsed_settings = described_class.parse(custom_settings) + + expect(parsed_settings['enabled']).to be(true) + expect(parsed_settings['key_file']).to eq(default_smime_key) + expect(parsed_settings['cert_file']).to eq(default_smime_cert) + end + + it 'enables smime with custom key and cert' do + custom_key = '/custom/key' + custom_cert = '/custom/cert' + custom_settings = Settingslogic.new({ + 'enabled' => true, + 'key_file' => custom_key, + 'cert_file' => custom_cert + }) + + parsed_settings = described_class.parse(custom_settings) + + expect(parsed_settings['enabled']).to be(true) + expect(parsed_settings['key_file']).to eq(custom_key) + expect(parsed_settings['cert_file']).to eq(custom_cert) + end + end + end +end diff --git a/spec/initializers/action_mailer_hooks_spec.rb b/spec/initializers/action_mailer_hooks_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3826ed9b00ac3af7b7c91b9fc51ac53ed2ee0212 --- /dev/null +++ b/spec/initializers/action_mailer_hooks_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe 'ActionMailer hooks' do + describe 'smime signature interceptor' do + before do + class_spy(ActionMailer::Base).as_stubbed_const + end + + it 'is disabled by default' do + load Rails.root.join('config/initializers/action_mailer_hooks.rb') + + expect(ActionMailer::Base).not_to( + have_received(:register_interceptor).with(Gitlab::Email::Hook::SmimeSignatureInterceptor)) + end + + describe 'interceptor testbed' do + where(:email_enabled, :email_smime_enabled, :smime_interceptor_enabled) do + [ + [false, false, false], + [false, true, false], + [true, false, false], + [true, true, true] + ] + end + + with_them do + before do + stub_config_setting(email_enabled: email_enabled) + stub_config_setting(email_smime: { enabled: email_smime_enabled }) + end + + it 'is enabled depending on settings' do + load Rails.root.join('config/initializers/action_mailer_hooks.rb') + + if smime_interceptor_enabled + expect(ActionMailer::Base).to( + have_received(:register_interceptor).with(Gitlab::Email::Hook::SmimeSignatureInterceptor)) + else + expect(ActionMailer::Base).not_to( + have_received(:register_interceptor).with(Gitlab::Email::Hook::SmimeSignatureInterceptor)) + end + end + end + end + end +end diff --git a/spec/lib/gitlab/email/hook/disable_email_interceptor_spec.rb b/spec/lib/gitlab/email/hook/disable_email_interceptor_spec.rb index 0c58cf088ccbc7101763b87ade4f4388fbe7191f..c8ed12523d0c079ba0b78a9fdfc91b174641f892 100644 --- a/spec/lib/gitlab/email/hook/disable_email_interceptor_spec.rb +++ b/spec/lib/gitlab/email/hook/disable_email_interceptor_spec.rb @@ -13,9 +13,6 @@ describe Gitlab::Email::Hook::DisableEmailInterceptor do end after do - # Removing interceptor from the list because unregister_interceptor is - # implemented in later version of mail gem - # See: https://github.com/mikel/mail/pull/705 Mail.unregister_interceptor(described_class) end diff --git a/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..35aa663b0a52ea571d0c19114ce09e5fb26e3084 --- /dev/null +++ b/spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Gitlab::Email::Hook::SmimeSignatureInterceptor do + include SmimeHelper + + # cert generation is an expensive operation and they are used read-only, + # so we share them as instance variables in all tests + before :context do + @root_ca = generate_root + @cert = generate_cert(root_ca: @root_ca) + end + + let(:root_certificate) do + Gitlab::Email::Smime::Certificate.new(@root_ca[:key], @root_ca[:cert]) + end + + let(:certificate) do + Gitlab::Email::Smime::Certificate.new(@cert[:key], @cert[:cert]) + end + + let(:mail) do + ActionMailer::Base.mail(to: 'test@example.com', from: 'info@example.com', body: 'signed hello') + end + + before do + allow(Gitlab::Email::Smime::Certificate).to receive_messages(from_files: certificate) + + Mail.register_interceptor(described_class) + mail.deliver_now + end + + after do + Mail.unregister_interceptor(described_class) + end + + it 'signs the email appropriately with SMIME' do + expect(mail.header['To'].value).to eq('test@example.com') + expect(mail.header['From'].value).to eq('info@example.com') + expect(mail.header['Content-Type'].value).to match('multipart/signed').and match('protocol="application/x-pkcs7-signature"') + + # verify signature and obtain pkcs7 encoded content + p7enc = Gitlab::Email::Smime::Signer.verify_signature( + cert: certificate.cert, + ca_cert: root_certificate.cert, + signed_data: mail.encoded) + + # envelope in a Mail object and obtain the body + decoded_mail = Mail.new(p7enc.data) + + expect(decoded_mail.body.encoded).to eq('signed hello') + end +end diff --git a/spec/lib/gitlab/email/smime/certificate_spec.rb b/spec/lib/gitlab/email/smime/certificate_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..90b276024130839ddf1df3f74bfea2c901a2f0a4 --- /dev/null +++ b/spec/lib/gitlab/email/smime/certificate_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Email::Smime::Certificate do + include SmimeHelper + + # cert generation is an expensive operation and they are used read-only, + # so we share them as instance variables in all tests + before :context do + @root_ca = generate_root + @cert = generate_cert(root_ca: @root_ca) + end + + describe 'testing environment setup' do + describe 'generate_root' do + subject { @root_ca } + + it 'generates a root CA that expires a long way in the future' do + expect(subject[:cert].not_after).to be > 999.years.from_now + end + end + + describe 'generate_cert' do + subject { @cert } + + it 'generates a cert properly signed by the root CA' do + expect(subject[:cert].issuer).to eq(@root_ca[:cert].subject) + end + + it 'generates a cert that expires soon' do + expect(subject[:cert].not_after).to be < 60.minutes.from_now + end + + it 'generates a cert intended for email signing' do + expect(subject[:cert].extensions).to include(an_object_having_attributes(oid: 'extendedKeyUsage', value: match('E-mail Protection'))) + end + + context 'passing in INFINITE_EXPIRY' do + subject { generate_cert(root_ca: @root_ca, expires_in: SmimeHelper::INFINITE_EXPIRY) } + + it 'generates a cert that expires a long way in the future' do + expect(subject[:cert].not_after).to be > 999.years.from_now + end + end + end + end + + describe '.from_strings' do + it 'parses correctly a certificate and key' do + parsed_cert = described_class.from_strings(@cert[:key].to_s, @cert[:cert].to_pem) + + common_cert_tests(parsed_cert, @cert, @root_ca) + end + end + + describe '.from_files' do + it 'parses correctly a certificate and key' do + allow(File).to receive(:read).with('a_key').and_return(@cert[:key].to_s) + allow(File).to receive(:read).with('a_cert').and_return(@cert[:cert].to_pem) + + parsed_cert = described_class.from_files('a_key', 'a_cert') + + common_cert_tests(parsed_cert, @cert, @root_ca) + end + end + + def common_cert_tests(parsed_cert, cert, root_ca) + expect(parsed_cert.cert).to be_a(OpenSSL::X509::Certificate) + expect(parsed_cert.cert.subject).to eq(cert[:cert].subject) + expect(parsed_cert.cert.issuer).to eq(root_ca[:cert].subject) + expect(parsed_cert.cert.not_before).to eq(cert[:cert].not_before) + expect(parsed_cert.cert.not_after).to eq(cert[:cert].not_after) + expect(parsed_cert.cert.extensions).to include(an_object_having_attributes(oid: 'extendedKeyUsage', value: match('E-mail Protection'))) + expect(parsed_cert.key).to be_a(OpenSSL::PKey::RSA) + end +end diff --git a/spec/lib/gitlab/email/smime/signer_spec.rb b/spec/lib/gitlab/email/smime/signer_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..56048b7148cc4b9a26956cc7bde89544dbaf084a --- /dev/null +++ b/spec/lib/gitlab/email/smime/signer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Email::Smime::Signer do + include SmimeHelper + + it 'signs data appropriately with SMIME' do + root_certificate = generate_root + certificate = generate_cert(root_ca: root_certificate) + + signed_content = described_class.sign( + cert: certificate[:cert], + key: certificate[:key], + data: 'signed content') + expect(signed_content).not_to be_nil + + p7enc = described_class.verify_signature( + cert: certificate[:cert], + ca_cert: root_certificate[:cert], + signed_data: signed_content) + + expect(p7enc).not_to be_nil + expect(p7enc.data).to eq('signed content') + end +end diff --git a/spec/support/helpers/smime_helper.rb b/spec/support/helpers/smime_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..656b3e196ba77fe2ec9ab7c2f3af880f16777721 --- /dev/null +++ b/spec/support/helpers/smime_helper.rb @@ -0,0 +1,55 @@ +module SmimeHelper + include OpenSSL + + INFINITE_EXPIRY = 1000.years + SHORT_EXPIRY = 30.minutes + + def generate_root + issue(signed_by: nil, expires_in: INFINITE_EXPIRY, certificate_authority: true) + end + + def generate_cert(root_ca:, expires_in: SHORT_EXPIRY) + issue(signed_by: root_ca, expires_in: expires_in, certificate_authority: false) + end + + # returns a hash { key:, cert: } containing a generated key, cert pair + def issue(email_address: 'test@example.com', signed_by:, expires_in:, certificate_authority:) + key = OpenSSL::PKey::RSA.new(4096) + public_key = key.public_key + + subject = if certificate_authority + X509::Name.parse("/CN=EU") + else + X509::Name.parse("/CN=#{email_address}") + end + + cert = X509::Certificate.new + cert.subject = subject + + cert.issuer = signed_by&.fetch(:cert, nil)&.subject || subject + + cert.not_before = Time.now + cert.not_after = expires_in.from_now + cert.public_key = public_key + cert.serial = 0x0 + cert.version = 2 + + extension_factory = X509::ExtensionFactory.new + if certificate_authority + extension_factory.subject_certificate = cert + extension_factory.issuer_certificate = cert + cert.add_extension(extension_factory.create_extension('subjectKeyIdentifier', 'hash')) + cert.add_extension(extension_factory.create_extension('basicConstraints', 'CA:TRUE', true)) + cert.add_extension(extension_factory.create_extension('keyUsage', 'cRLSign,keyCertSign', true)) + else + cert.add_extension(extension_factory.create_extension('subjectAltName', "email:#{email_address}", false)) + cert.add_extension(extension_factory.create_extension('basicConstraints', 'CA:FALSE', true)) + cert.add_extension(extension_factory.create_extension('keyUsage', 'digitalSignature,keyEncipherment', true)) + cert.add_extension(extension_factory.create_extension('extendedKeyUsage', 'clientAuth,emailProtection', false)) + end + + cert.sign(signed_by&.fetch(:key, nil) || key, Digest::SHA256.new) + + { key: key, cert: cert } + end +end