Dual certificate support (eg ECDSA with RSA fallback) (#1801)

* feat: Change Postfix smtpd_tls key and cert files to chain_files

Since Postfix 3.4, `smtpd_tls_cert_file` and `smtpd_tls_key_file` have been deprecated in favor of `smtpd_tls_chain_files` which supports a list of values where a single or sequence of file paths provide a private key followed by it's certificate chain.

* feat: Dual certificate support

`smtpd_tls_chain_files` allows for multiple key+cert bundles so that you can provide different key types, such as ECDSA and RSA.

To maintain compatibility with the current CERT/KEY ENV vars only a 2nd certificate is supported.

Since Dovecot 2.2.31 a related feature is also available, but it is limited to only providing one alternative certificate via separate cert and key settings.

---

This feature enables support for multiple certificates, eg for serving modern ECDSA certs with RSA as fallback.

* chore: Refactor variable names to meet style guide

Improved some comments too.

* chore: Have function definitions respect style guide

* chore: Minor edits to comments

* chore: Expand on comments for maintenance, alert of insecure config

When `SSL_TYPE` isn't properly setup, we're still offering SSL connections but not warning in logs about the insecurity of such, or why a misconfiguration may have occurred.

This commit more clearly communicates to the user that they should look into the issue before considering deploying to production.

The `TODO` comments communicate to any future maintainer to consider treating these improper configs as disabling TLS instead.

* fix: Use `snakeoil` cert

I mistakenly thought this was placeholder text, which broke some tests. This adds the two files in the correct order (private key followed by cert/chain), to fix that issue.

* fix: Disable alt cert for Dovecot if necessary

Certain scenarios may persist state of previously configured alt cert via ENV vars that are removed from a future run. If the config is not reset to original immutable state, this will correctly disable the config from using alt cert unintentionally.

* fix: Satisfy ShellCheck lint

By switching from string var to array / list expansion, this better stores the extracted result and applies it in a manner that ShellCheck linting approves, removing the need to disable the rule.

* feat: Support dual cert test

Few tweaks to the test script allows re-purposing it for covering dual cert support as well.

* chore: Rearranged cert and key lines

A little reorganization, mostly placing private key ahead of related cert lines.

* chore: Refactor `_set_certificate`

This should make the parameters a little less confusing.

Previously was 3 parameters, but the Postfix parameter (1st) may look like two variables if you don't pay attention to the surrounding quotes; while the Dovecot parameters (2nd + 3rd) would have an opposing order. There was also a variant where the `FULLKEYCHAIN` var was passed in three times.

Now it's two params, with the 2nd param as an optional one. If the 2nd param is provided, then the two params are in the order of private key then certificate, otherwise if only a single parameter it's a single PEM file with the full cert chain and private key bundled.

This avoids implying that Postfix and Dovecot might use different files.

* chore: Document current state of `SSL_TYPE` logic better

Inlined for the benefit of anyone else maintaining this section if I'm unable to address the concerns within my own time.

* docs: ENV vars

`TLS_LEVEL=old` isn't in the codebase anymore, not likely to be relevant to retain.

No point in documenting what is considered invalid / unsupported config value in the first place for `SSL_TYPE`.

`SSL_TYPE=manual` was missing documentation for both related file path ENV vars, they've been added along with their alt fallback variants.

* chore: Update Dovecot LMTP SSL test config

Not sure how relevant this is, the file isn't complete sync with the main dovecot `10-ssl.conf` config, adding the support just in case.

* chore: Rename `FULLKEYCHAIN` to avoid confusion

There doesn't appear to be a standardized name for this type of file bundle, and `keychain` may be misleading (fullkeychain often provides macOS keychain  results on search engines).

Opting for a more explicit `KEY_WITH_FULLCHAIN` name instead.

* fix: Invalid var name

`_set_certificate` refactor commit accidentally changed a var name and committed that breaking the dual cert support (thanks tests!).

* test: Refactor `mail_ssl_manual.bats`

Proper test return values instead of `wc -l` based checking.

Tests with dual cert support active, tests that feature (to better detect failure case.

Third test case was unable to verify new self-signed certificate, added new certs signed with self-signed root CA.

Adjusted openssl `CApath` parameter to use `CAfile` instead as `letsencrypt` cert was replaced thus CA cert is missing from the system trust store.

* test: Properly check for files in `mail_ssl_manual.bats`

Fixes lint error.

Also realized I was accidentally asserting a file exists in the test environment, not within the container.

Resolved that and also added an additional test case to ensure the ENV var files are valid when passed in, in the event a change misconfigures them and that the issue is identified earlier.

* chore: Apply PR review feedback

Better format some strings that had mixed quotes when they weren't necessary.

Additionally DRYed up the config path for Postfix and Dovecot within the `_setup_ssl` method.

Co-authored-by: Georg Lauterbach <infrastructure@itbsd.com>
This commit is contained in:
Brennan Kinney 2021-02-22 11:43:41 +13:00 committed by GitHub
parent a7ecb0ea8b
commit d02ebc922c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 624 additions and 113 deletions

View file

@ -1135,21 +1135,80 @@ function _setup_ssl
{
_notify 'task' 'Setting up SSL'
function _apply_tls_level()
local POSTFIX_CONFIG_MAIN='/etc/postfix/main.cf'
local DOVECOT_CONFIG_SSL='/etc/dovecot/conf.d/10-ssl.conf'
# Primary certificate to serve for TLS
# NOTE: The `sed` substituion delimiter uses `~` instead of `/` due to file paths as values
function _set_certificate
{
local POSTFIX_KEY_WITH_FULLCHAIN=$1
local DOVECOT_KEY=$1
local DOVECOT_CERT=$1
# If 2nd param is provided, we've been provided separate key and cert instead of a fullkeychain
if [[ -n $2 ]]
then
local PRIVATE_KEY=$1
local CERT_CHAIN=$2
POSTFIX_KEY_WITH_FULLCHAIN="${PRIVATE_KEY} ${CERT_CHAIN}"
DOVECOT_KEY="${PRIVATE_KEY}"
DOVECOT_CERT="${CERT_CHAIN}"
fi
# Postfix configuration
# NOTE: `smtpd_tls_chain_files` expects private key defined before public cert chain
# May be a single PEM file or a sequence of files, so long as the order is key->leaf->chain
sed -i "s~^smtpd_tls_chain_files =.*~smtpd_tls_chain_files = ${POSTFIX_KEY_WITH_FULLCHAIN}~" "${POSTFIX_CONFIG_MAIN}"
# Dovecot configuration
sed -i "s~^ssl_key = <.*~ssl_key = <${DOVECOT_KEY}~" "${DOVECOT_CONFIG_SSL}"
sed -i "s~^ssl_cert = <.*~ssl_cert = <${DOVECOT_CERT}~" "${DOVECOT_CONFIG_SSL}"
}
# Enables supporting two certificate types such as ECDSA with an RSA fallback
function _set_alt_certificate
{
local COPY_KEY_FROM_PATH=$1
local COPY_CERT_FROM_PATH=$2
local PRIVATE_KEY_ALT='/etc/postfix/ssl/fallback_key'
local CERT_CHAIN_ALT='/etc/postfix/ssl/fallback_cert'
cp "${COPY_KEY_FROM_PATH}" "${PRIVATE_KEY_ALT}"
cp "${COPY_CERT_FROM_PATH}" "${CERT_CHAIN_ALT}"
chmod 600 "${PRIVATE_KEY_ALT}"
chmod 600 "${CERT_CHAIN_ALT}"
# Postfix configuration
# NOTE: This operation doesn't replace the line, it appends to the end of the line.
# Thus this method should only be used when this line has explicitly been replaced earlier in the script.
# Otherwise without `docker-compose down` first, a `docker-compose up` may
# persist previous container state and cause a failure in postfix configuration.
sed -i "s~^smtpd_tls_chain_files =.*~& ${PRIVATE_KEY_ALT} ${CERT_CHAIN_ALT}~" "${POSTFIX_CONFIG_MAIN}"
# Dovecot configuration
# Conditionally checks for `#`, in the event that internal container state is accidentally persisted,
# can be caused by: `docker-compose up` run again after a `ctrl+c`, without running `docker-compose down`
sed -i "s~^#\?ssl_alt_key = <.*~ssl_alt_key = <${PRIVATE_KEY_ALT}~" "${DOVECOT_CONFIG_SSL}"
sed -i "s~^#\?ssl_alt_cert = <.*~ssl_alt_cert = <${CERT_CHAIN_ALT}~" "${DOVECOT_CONFIG_SSL}"
}
function _apply_tls_level
{
local TLS_CIPHERS_ALLOW=$1
local TLS_PROTOCOL_IGNORE=$2
local TLS_PROTOCOL_MINIMUM=$3
# Postfix configuration
sed -i 's/^smtpd_tls_mandatory_protocols =.*/smtpd_tls_mandatory_protocols = '"${TLS_PROTOCOL_IGNORE}/" /etc/postfix/main.cf
sed -i 's/^smtpd_tls_protocols =.*/smtpd_tls_protocols = '"${TLS_PROTOCOL_IGNORE}/" /etc/postfix/main.cf
sed -i 's/^smtp_tls_protocols =.*/smtp_tls_protocols = '"${TLS_PROTOCOL_IGNORE}/" /etc/postfix/main.cf
sed -i 's/^tls_high_cipherlist =.*/tls_high_cipherlist = '"${TLS_CIPHERS_ALLOW}/" /etc/postfix/main.cf
sed -i "s/^smtpd_tls_mandatory_protocols =.*/smtpd_tls_mandatory_protocols = ${TLS_PROTOCOL_IGNORE}/" "${POSTFIX_CONFIG_MAIN}"
sed -i "s/^smtpd_tls_protocols =.*/smtpd_tls_protocols = ${TLS_PROTOCOL_IGNORE}/" "${POSTFIX_CONFIG_MAIN}"
sed -i "s/^smtp_tls_protocols =.*/smtp_tls_protocols = ${TLS_PROTOCOL_IGNORE}/" "${POSTFIX_CONFIG_MAIN}"
sed -i "s/^tls_high_cipherlist =.*/tls_high_cipherlist = ${TLS_CIPHERS_ALLOW}/" "${POSTFIX_CONFIG_MAIN}"
# Dovecot configuration (secure by default though)
sed -i 's/^ssl_min_protocol =.*/ssl_min_protocol = '"${TLS_PROTOCOL_MINIMUM}/" /etc/dovecot/conf.d/10-ssl.conf
sed -i 's/^ssl_cipher_list =.*/ssl_cipher_list = '"${TLS_CIPHERS_ALLOW}/" /etc/dovecot/conf.d/10-ssl.conf
sed -i "s/^ssl_min_protocol =.*/ssl_min_protocol = ${TLS_PROTOCOL_MINIMUM}/" "${DOVECOT_CONFIG_SSL}"
sed -i "s/^ssl_cipher_list =.*/ssl_cipher_list = ${TLS_CIPHERS_ALLOW}/" "${DOVECOT_CONFIG_SSL}"
}
# TLS strength/level configuration
@ -1181,6 +1240,8 @@ function _setup_ssl
esac
# SSL certificate Configuration
# TODO: Refactor this feature, it's been extended multiple times for specific inputs/providers unnecessarily.
# NOTE: Some `SSL_TYPE` logic uses mounted certs/keys directly, some make an internal copy either retaining filename or renaming, chmod inconsistent.
case "${SSL_TYPE}" in
"letsencrypt" )
_notify 'inf' "Configuring SSL using 'letsencrypt'"
@ -1188,6 +1249,9 @@ function _setup_ssl
local LETSENCRYPT_DOMAIN=""
local LETSENCRYPT_KEY=""
# 2020 feature intended for Traefik v2 support only:
# https://github.com/docker-mailserver/docker-mailserver/pull/1553
# Uses `key.pem` and `fullchain.pem`
if [[ -f /etc/letsencrypt/acme.json ]]
then
if ! _extract_certs_from_acme "${SSL_DOMAIN}"
@ -1231,13 +1295,14 @@ function _setup_ssl
then
_notify 'inf' "Adding ${LETSENCRYPT_DOMAIN} SSL certificate to the postfix and dovecot configuration"
# Postfix configuration
sed -i -r 's~smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem~smtpd_tls_cert_file=/etc/letsencrypt/live/'"${LETSENCRYPT_DOMAIN}"'/fullchain.pem~g' /etc/postfix/main.cf
sed -i -r 's~smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key~smtpd_tls_key_file=/etc/letsencrypt/live/'"${LETSENCRYPT_DOMAIN}"'/'"${LETSENCRYPT_KEY}"'\.pem~g' /etc/postfix/main.cf
# LetsEncrypt `fullchain.pem` and `privkey.pem` contents are detailed here from CertBot:
# https://certbot.eff.org/docs/using.html#where-are-my-certificates
# `key.pem` was added for `simp_le` support (2016): https://github.com/docker-mailserver/docker-mailserver/pull/288
# `key.pem` is also a filename used by the `_extract_certs_from_acme` method (implemented for Traefik v2 only)
local PRIVATE_KEY="/etc/letsencrypt/live/${LETSENCRYPT_DOMAIN}/${LETSENCRYPT_KEY}.pem"
local CERT_CHAIN="/etc/letsencrypt/live/${LETSENCRYPT_DOMAIN}/fullchain.pem"
# Dovecot configuration
sed -i -e 's~ssl_cert = </etc/dovecot/ssl/dovecot\.pem~ssl_cert = </etc/letsencrypt/live/'"${LETSENCRYPT_DOMAIN}"'/fullchain\.pem~g' /etc/dovecot/conf.d/10-ssl.conf
sed -i -e 's~ssl_key = </etc/dovecot/ssl/dovecot\.key~ssl_key = </etc/letsencrypt/live/'"${LETSENCRYPT_DOMAIN}"'/'"${LETSENCRYPT_KEY}"'\.pem~g' /etc/dovecot/conf.d/10-ssl.conf
_set_certificate "${PRIVATE_KEY}" "${CERT_CHAIN}"
_notify 'inf' "SSL configured with 'letsencrypt' certificates"
fi
@ -1252,85 +1317,93 @@ function _setup_ssl
mkdir -p /etc/postfix/ssl
cp "/tmp/docker-mailserver/ssl/${HOSTNAME}-full.pem" /etc/postfix/ssl
# Postfix configuration
sed -i -r 's~smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem~smtpd_tls_cert_file=/etc/postfix/ssl/'"${HOSTNAME}"'-full.pem~g' /etc/postfix/main.cf
sed -i -r 's~smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key~smtpd_tls_key_file=/etc/postfix/ssl/'"${HOSTNAME}"'-full.pem~g' /etc/postfix/main.cf
# Private key with full certificate chain all in a single PEM file
# NOTE: Dovecot works fine still as both values are bundled into the keychain
local KEY_WITH_FULLCHAIN='/etc/postfix/ssl/'"${HOSTNAME}"'-full.pem'
# Dovecot configuration
sed -i -e 's~ssl_cert = </etc/dovecot/ssl/dovecot\.pem~ssl_cert = </etc/postfix/ssl/'"${HOSTNAME}"'-full\.pem~g' /etc/dovecot/conf.d/10-ssl.conf
sed -i -e 's~ssl_key = </etc/dovecot/ssl/dovecot\.key~ssl_key = </etc/postfix/ssl/'"${HOSTNAME}"'-full\.pem~g' /etc/dovecot/conf.d/10-ssl.conf
_set_certificate "${KEY_WITH_FULLCHAIN}"
_notify 'inf' "SSL configured with 'CA signed/custom' certificates"
fi
;;
"manual" )
# Lets you manually specify the location of the SSL Certs to use. This gives you some more control over this whole processes (like using kube-lego to generate certs)
# Lets you manually specify the location of the SSL Certs to use. This gives you some more control over this whole processes (like using kube-lego to generate certs)
if [[ -n ${SSL_CERT_PATH} ]] && [[ -n ${SSL_KEY_PATH} ]]
then
_notify 'inf' "Configuring certificates using cert ${SSL_CERT_PATH} and key ${SSL_KEY_PATH}"
mkdir -p /etc/postfix/ssl
cp "${SSL_CERT_PATH}" /etc/postfix/ssl/cert
cp "${SSL_KEY_PATH}" /etc/postfix/ssl/key
chmod 600 /etc/postfix/ssl/cert
cp "${SSL_CERT_PATH}" /etc/postfix/ssl/cert
chmod 600 /etc/postfix/ssl/key
chmod 600 /etc/postfix/ssl/cert
# Postfix configuration
sed -i -r 's~smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem~smtpd_tls_cert_file=/etc/postfix/ssl/cert~g' /etc/postfix/main.cf
sed -i -r 's~smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key~smtpd_tls_key_file=/etc/postfix/ssl/key~g' /etc/postfix/main.cf
local PRIVATE_KEY='/etc/postfix/ssl/key'
local CERT_CHAIN='/etc/postfix/ssl/cert'
# Dovecot configuration
sed -i -e 's~ssl_cert = </etc/dovecot/ssl/dovecot\.pem~ssl_cert = </etc/postfix/ssl/cert~g' /etc/dovecot/conf.d/10-ssl.conf
sed -i -e 's~ssl_key = </etc/dovecot/ssl/dovecot\.key~ssl_key = </etc/postfix/ssl/key~g' /etc/dovecot/conf.d/10-ssl.conf
_set_certificate "${PRIVATE_KEY}" "${CERT_CHAIN}"
# Support for a fallback certificate, useful for hybrid/dual ECDSA + RSA certs
if [[ -n ${SSL_ALT_KEY_PATH} ]] && [[ -n ${SSL_ALT_CERT_PATH} ]]
then
_notify 'inf' "Configuring alternative certificates using cert ${SSL_ALT_CERT_PATH} and key ${SSL_ALT_KEY_PATH}"
_set_alt_certificate "${SSL_ALT_KEY_PATH}" "${SSL_ALT_CERT_PATH}"
else
# If the Dovecot settings for alt cert has been enabled (doesn't start with `#`),
# but required ENV var is missing, reset to disabled state:
sed -i 's~^ssl_alt_key = <.*~#ssl_alt_key = </path/to/alternative/key.pem~' "${DOVECOT_CONFIG_SSL}"
sed -i 's~^ssl_alt_cert = <.*~#ssl_alt_cert = </path/to/alternative/cert.pem~' "${DOVECOT_CONFIG_SSL}"
fi
_notify 'inf' "SSL configured with 'Manual' certificates"
fi
;;
"self-signed" )
# Adding self-signed SSL certificate if provided in 'postfix/ssl' folder
if [[ -e /tmp/docker-mailserver/ssl/${HOSTNAME}-cert.pem ]] \
&& [[ -e /tmp/docker-mailserver/ssl/${HOSTNAME}-key.pem ]] \
&& [[ -e /tmp/docker-mailserver/ssl/${HOSTNAME}-combined.pem ]] \
if [[ -e /tmp/docker-mailserver/ssl/${HOSTNAME}-key.pem ]] \
&& [[ -e /tmp/docker-mailserver/ssl/${HOSTNAME}-cert.pem ]] \
&& [[ -e /tmp/docker-mailserver/ssl/demoCA/cacert.pem ]]
then
_notify 'inf' "Adding ${HOSTNAME} SSL certificate"
mkdir -p /etc/postfix/ssl
cp "/tmp/docker-mailserver/ssl/${HOSTNAME}-cert.pem" /etc/postfix/ssl
cp "/tmp/docker-mailserver/ssl/${HOSTNAME}-key.pem" /etc/postfix/ssl
# Force permission on key file
cp "/tmp/docker-mailserver/ssl/${HOSTNAME}-cert.pem" /etc/postfix/ssl
chmod 600 "/etc/postfix/ssl/${HOSTNAME}-key.pem"
cp "/tmp/docker-mailserver/ssl/${HOSTNAME}-combined.pem" /etc/postfix/ssl
local PRIVATE_KEY="/etc/postfix/ssl/${HOSTNAME}-key.pem"
local CERT_CHAIN="/etc/postfix/ssl/${HOSTNAME}-cert.pem"
_set_certificate "${PRIVATE_KEY}" "${CERT_CHAIN}"
cp /tmp/docker-mailserver/ssl/demoCA/cacert.pem /etc/postfix/ssl
# Postfix configuration
sed -i -r 's~smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem~smtpd_tls_cert_file=/etc/postfix/ssl/'"${HOSTNAME}"'-cert.pem~g' /etc/postfix/main.cf
sed -i -r 's~smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key~smtpd_tls_key_file=/etc/postfix/ssl/'"${HOSTNAME}"'-key.pem~g' /etc/postfix/main.cf
sed -i -r 's~#smtpd_tls_CAfile=~smtpd_tls_CAfile=/etc/postfix/ssl/cacert.pem~g' /etc/postfix/main.cf
sed -i -r 's~#smtp_tls_CAfile=~smtp_tls_CAfile=/etc/postfix/ssl/cacert.pem~g' /etc/postfix/main.cf
ln -s /etc/postfix/ssl/cacert.pem "/etc/ssl/certs/cacert-${HOSTNAME}.pem"
# Dovecot configuration
sed -i -e 's~ssl_cert = </etc/dovecot/ssl/dovecot\.pem~ssl_cert = </etc/postfix/ssl/'"${HOSTNAME}"'-combined\.pem~g' /etc/dovecot/conf.d/10-ssl.conf
sed -i -e 's~ssl_key = </etc/dovecot/ssl/dovecot\.key~ssl_key = </etc/postfix/ssl/'"${HOSTNAME}"'-key\.pem~g' /etc/dovecot/conf.d/10-ssl.conf
# Have Postfix trust the self-signed CA (which is not installed within the OS trust store)
sed -i -r 's~^#?smtpd_tls_CAfile =.*~smtpd_tls_CAfile = /etc/postfix/ssl/cacert.pem~' "${POSTFIX_CONFIG_MAIN}"
sed -i -r 's~^#?smtp_tls_CAfile =.*~smtp_tls_CAfile = /etc/postfix/ssl/cacert.pem~' "${POSTFIX_CONFIG_MAIN}"
# Part of the original `self-signed` support, unclear why this symlink was required?
# May have been to support the now removed `Courier` (Dovecot replaced it):
# https://github.com/docker-mailserver/docker-mailserver/commit/1fb3aeede8ac9707cc9ea11d603e3a7b33b5f8d5
local PRIVATE_CA="/etc/ssl/certs/cacert-${HOSTNAME}.pem"
ln -s /etc/postfix/ssl/cacert.pem "${PRIVATE_CA}"
_notify 'inf' "SSL configured with 'self-signed' certificates"
fi
;;
'' )
# no SSL certificate, plain text access
# TODO: Postfix configuration still responds to TLS negotiations using snakeoil cert from default config
# TODO: Dovecot `ssl = yes` also allows TLS, both cases this is insecure and should probably instead enforce no TLS?
# Dovecot configuration
sed -i -e 's~#disable_plaintext_auth = yes~disable_plaintext_auth = no~g' /etc/dovecot/conf.d/10-auth.conf
sed -i -e 's~ssl = required~ssl = yes~g' /etc/dovecot/conf.d/10-ssl.conf
sed -i -e 's~ssl = required~ssl = yes~g' "${DOVECOT_CONFIG_SSL}"
_notify 'inf' "SSL configured with plain text access"
_notify 'warn' "(INSECURE!) SSL configured with plain text access. DO NOT USE FOR PRODUCTION DEPLOYMENT."
;;
* )
# Unknown option, default behavior, no action is required
_notify 'warn' "SSL configured by default"
_notify 'warn' "(INSECURE!) ENV var 'SSL_TYPE' is invalid. DO NOT USE FOR PRODUCTION DEPLOYMENT."
;;
esac
}