Skip to content
Snippets Groups Projects
Commit 0dcb9d21 authored by Diego Louzán's avatar Diego Louzán
Browse files

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
parent d8966abd
No related branches found
No related tags found
No related merge requests found
Showing
with 515 additions and 3 deletions
Loading
Loading
@@ -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/
Loading
Loading
---
title: Notification emails can be signed with SMIME
merge_request: 30644
author: Diego Louzán
type: added
Loading
Loading
@@ -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
 
Loading
Loading
require_relative '../settings'
require_relative '../object_store_settings'
require_relative '../smime_signature_settings'
 
# Default settings
Settings['ldap'] ||= Settingslogic.new({})
Loading
Loading
@@ -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'
Loading
Loading
Loading
Loading
@@ -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
# 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
Loading
Loading
@@ -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
 
Loading
Loading
# 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
```
Loading
Loading
@@ -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
Loading
Loading
# 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
# 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
# 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
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
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
Loading
Loading
@@ -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
 
Loading
Loading
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
# 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
# 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
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
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment