diff options
author | Diego Louzán <diego.louzan.ext@siemens.com> | 2019-07-10 22:40:28 +0300 |
---|---|---|
committer | Diego Louzán <diego.louzan.ext@siemens.com> | 2019-08-20 17:13:32 +0300 |
commit | 0dcb9d21efc1db97765d82ee39a0f0905ba945ba (patch) | |
tree | 48b0fa42bbe0186e28758ba496f45ef11972aed6 /spec | |
parent | d8966abd20c860d2f30141f3647f2b81f70b683d (diff) |
feat: SMIME signed notification emails
- Add mail interceptor the signs outgoing email with SMIME
- Add lib and helpers to work with SMIME data
- New configuration params for setting up SMIME key and cert files
Diffstat (limited to 'spec')
-rw-r--r-- | spec/config/smime_signature_settings_spec.rb | 56 | ||||
-rw-r--r-- | spec/initializers/action_mailer_hooks_spec.rb | 46 | ||||
-rw-r--r-- | spec/lib/gitlab/email/hook/disable_email_interceptor_spec.rb | 3 | ||||
-rw-r--r-- | spec/lib/gitlab/email/hook/smime_signature_interceptor_spec.rb | 52 | ||||
-rw-r--r-- | spec/lib/gitlab/email/smime/certificate_spec.rb | 77 | ||||
-rw-r--r-- | spec/lib/gitlab/email/smime/signer_spec.rb | 26 | ||||
-rw-r--r-- | spec/support/helpers/smime_helper.rb | 55 |
7 files changed, 312 insertions, 3 deletions
diff --git a/spec/config/smime_signature_settings_spec.rb b/spec/config/smime_signature_settings_spec.rb new file mode 100644 index 00000000000..4f0c227d866 --- /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 00000000000..3826ed9b00a --- /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 0c58cf088cc..c8ed12523d0 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 00000000000..35aa663b0a5 --- /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 00000000000..90b27602413 --- /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 00000000000..56048b7148c --- /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 00000000000..656b3e196ba --- /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 |