From a2b80841332e076eb54db23040ffcd06af3e3f8f Mon Sep 17 00:00:00 2001 From: Aki Tuomi Date: Thu, 24 Sep 2026 10:40:23 +0000 Subject: [PATCH] settings: Add TLS certificate requirement settings Document ssl_server_require_client_cert and ssl_client_require_server_cert, named list filters that restrict the issuer and subject of the client certificate of incoming TLS connections and of the server certificate of outgoing ones, with a Certificate Requirements section in ssl.md. Also document haproxy_ssl_client_cert_subject_tlv and haproxy_ssl_client_cert_issuer_tlv, which pass the certificate DNs through HAProxy, and note that the certificate username is now converted to UTF-8. --- .github/actions/spelling/allow.txt | 4 + data/settings.js | 176 +++++++++++++++++++++++- data/updates.js | 1 + docs/core/config/ssl.md | 214 ++++++++++++++++++++++++++++- 4 files changed, 390 insertions(+), 5 deletions(-) diff --git a/.github/actions/spelling/allow.txt b/.github/actions/spelling/allow.txt index e33898afe..bccf4ad30 100644 --- a/.github/actions/spelling/allow.txt +++ b/.github/actions/spelling/allow.txt @@ -1,4 +1,5 @@ aarch +abns authid authzid APPENDLIMIT @@ -14,6 +15,7 @@ frop fuzzer github hardenings +hostaddr https iostream iostreams @@ -50,6 +52,8 @@ sls sni spnego ssh +tlv +TLVs ubsan ubuntu Uppercases diff --git a/data/settings.js b/data/settings.js index 48e0fef40..5d1b63627 100644 --- a/data/settings.js +++ b/data/settings.js @@ -6555,6 +6555,62 @@ range for users. A user whose UID is outside this range is not allowed to log in.` }, + haproxy_ssl_client_cert_issuer_tlv: { + added: { + settings_ssl_cert_requirements_added: false, + }, + tags: [ 'haproxy' ], + seealso: [ + 'haproxy_ssl_client_cert_subject_tlv', + 'ssl_server_require_client_cert', + '[[link,ssl_certificate_requirements]]', + ], + values: setting_types.STRING, + text: ` +The type of the PROXY protocol v2 TLV in which HAProxy sends the issuer DN of +the client certificate, \`0xE0\` to \`0xEF\`. Must be set together with +[[setting,haproxy_ssl_client_cert_subject_tlv]], with a different type. Used +only for [[setting,ssl_server_require_client_cert]] blocks. + +Only the service-level value is used. A value inside a filter is ignored. + +Example for the HAProxy \`server\` line: + +\`\`\` +set-proxy-v2-tlv-fmt(0xE2) %[ssl_c_i_dn(,0,rfc2253)] +\`\`\` + +See [[link,ssl_certificate_requirements]].` + }, + + haproxy_ssl_client_cert_subject_tlv: { + added: { + settings_ssl_cert_requirements_added: false, + }, + tags: [ 'haproxy' ], + seealso: [ + 'haproxy_ssl_client_cert_issuer_tlv', + 'ssl_server_require_client_cert', + '[[link,ssl_certificate_requirements]]', + ], + values: setting_types.STRING, + text: ` +The type of the PROXY protocol v2 TLV in which HAProxy sends the subject DN of +the client certificate, \`0xE0\` to \`0xEF\`. Must be set together with +[[setting,haproxy_ssl_client_cert_issuer_tlv]], with a different type. Used +only for [[setting,ssl_server_require_client_cert]] blocks. + +Only the service-level value is used. A value inside a filter is ignored. + +Example for the HAProxy \`server\` line: + +\`\`\` +set-proxy-v2-tlv-fmt(0xE1) %[ssl_c_s_dn(,0,rfc2253)] +\`\`\` + +See [[link,ssl_certificate_requirements]].` + }, + haproxy_timeout: { default: '3secs', tags: [ 'haproxy' ], @@ -12096,16 +12152,72 @@ ssl_server_dh_file = /path/to/dh.pem \`\`\`` }, + ssl_client_require_server_cert: { + added: { + settings_ssl_cert_requirements_added: false, + }, + seealso: [ + 'ssl_client_require_valid_cert', + 'ssl_server_require_client_cert', + '[[link,ssl_configuration]]', + '[[link,settings_syntax_named_filters]]', + ], + tags: [ 'ssl_client' ], + values: setting_types.NAMED_LIST_FILTER, + text: ` +Restrict which server certificates are accepted for outgoing TLS +connections. Each block must have a unique name and \`issuer\` and +\`subject\` sections of \`key = value\` lines. See +[[link,ssl_certificate_requirements]] for the matching rules. + +The server certificate must satisfy at least one block, and every key in +that block's \`issuer\` and \`subject\` sections. The connection fails +otherwise, with the error \`Server certificate does not satisfy any +ssl_client_require_server_cert block\`. + +Requires [[setting,ssl_client_require_valid_cert,yes]]; otherwise creating +the TLS context fails. LDAP checks the blocks with \`ldaps\` URIs and +[[setting,ldap_starttls]]. MySQL (with [[setting,mysql_ssl,yes]]), Cassandra +and PostgreSQL connections don't support this setting and fail if it applies +to them. PostgreSQL is excepted only with \`sslmode=disable\` in +[[setting,pgsql_parameters]], or if every host is a unix socket path and +\`pgsql_parameters\` has no \`host\`, \`hostaddr\` or \`service\` key. TLS +renegotiation is refused on connections with blocks. + +A block outside of any filter applies to all outgoing TLS connections, such +as proxying to a backend with a self-signed certificate. Scope it with a +filter. + +Example: + +\`\`\`[dovecot.conf] +protocol imap { + ssl_client_require_server_cert backend-ca { + issuer { + O = Example Corp + CN = Example Backend CA + } + subject { + CN = backend.example.com + } + } +} +\`\`\` +` + }, + ssl_client_require_valid_cert: { default: 'yes', - seealso: [ 'ssl', '[[link,ssl_configuration]]' ], + seealso: [ 'ssl', 'ssl_client_require_server_cert', '[[link,ssl_configuration]]' ], tags: [ 'ssl-ldap', 'ssl-cassandra', 'ssl_client', 'sql-mysql' ], values: setting_types.BOOLEAN, text: ` Require a valid certificate when connecting to external SSL services? To change this for a single client only, set it inside that client's filter, -e.g. [[setting,imapc]] or [[setting,pop3c]].` +e.g. [[setting,imapc]] or [[setting,pop3c]]. + +Required by [[setting,ssl_client_require_server_cert]].` }, ssl_server_key_file: { @@ -12204,6 +12316,61 @@ Whether to give preference to the server's cipher list over a client's list.` }, + ssl_server_require_client_cert: { + added: { + settings_ssl_cert_requirements_added: false, + }, + seealso: [ + 'ssl_server_request_client_cert', + 'ssl_client_require_server_cert', + 'ssl_server_cert_username_field', + '[[link,ssl_configuration]]', + '[[link,settings_syntax_named_filters]]', + ], + values: setting_types.NAMED_LIST_FILTER, + text: ` +Restrict which client certificates are accepted for incoming TLS +connections. Each block must have a unique name and \`issuer\` and +\`subject\` sections of \`key = value\` lines. See +[[link,ssl_certificate_requirements]] for the matching rules. + +The client certificate must satisfy at least one block, and every key in +that block's \`issuer\` and \`subject\` sections. A client with no +certificate, or one that satisfies no block, is rejected in the TLS +handshake. TLS session resumption is disabled where blocks are set. + +Requires [[setting,ssl_server_request_client_cert,yes]]; with \`no\` or +\`any-cert\` creating the TLS context fails. Blocks are ignored with +[[setting,ssl,no]], except for connections from HAProxy. + +Only TLS handshakes are checked: use [[setting,ssl,required]]. Behind +HAProxy the connection must carry the SSL TLV and a verified certificate. The +subject and issuer DNs are matched if +[[setting,haproxy_ssl_client_cert_subject_tlv]] and +[[setting,haproxy_ssl_client_cert_issuer_tlv]] are set, otherwise only blocks +with no \`issuer\` keys and a single \`subject\` \`CN\` key are evaluated, against +the CN HAProxy forwards. A connection that fails this is rejected. + +Example: + +\`\`\`[dovecot.conf] +ssl_server_request_client_cert = yes + +protocol imap { + ssl_server_require_client_cert x1 { + issuer { + O = Example Corp + CN = Example Client CA + } + subject { + OU = Mail Clients + } + } +} +\`\`\` +` + }, + ssl_server_require_crl: { default: 'yes', seealso: [ 'ssl', 'ssl_server_ca_file', '[[link,ssl_configuration]]' ], @@ -12223,6 +12390,7 @@ Renamed from \`ssl_verify_client_cert\` setting.` seealso: [ 'ssl', 'auth_ssl_require_client_cert', + 'ssl_server_require_client_cert', '[[link,ssl_configuration]]', ], values: setting_types.ENUM, @@ -12235,7 +12403,9 @@ authentication to check the client certificate with [[link,passdb_check_client_f variant) extra field. See [[link,passdb_check_client_fp]]. Note: This setting doesn't yet require the certificate to be valid or -to even exist. See [[setting,auth_ssl_require_client_cert]].` +to even exist. See [[setting,auth_ssl_require_client_cert]]. To restrict the +issuer or subject, see [[setting,ssl_server_require_client_cert]], which +requires \`yes\`.` }, ssl_server: { diff --git a/data/updates.js b/data/updates.js index 99509f71f..fa0de01b3 100644 --- a/data/updates.js +++ b/data/updates.js @@ -241,6 +241,7 @@ export const updates = { settings_sieve_notify_mailto_max_recipients_added: '2.4.5', settings_sieve_notify_max_notifications_added: '2.4.5', settings_syntax_heredoc_added: '2.4.5', + settings_ssl_cert_requirements_added: '2.4.6', settings_ssl_client_ca_added: '2.4.0', settings_ssl_imapc_removed: '2.4.0', settings_ssl_request_client_cert_changed: '2.4.0', diff --git a/docs/core/config/ssl.md b/docs/core/config/ssl.md index 3feeda877..ec019ce30 100644 --- a/docs/core/config/ssl.md +++ b/docs/core/config/ssl.md @@ -2,6 +2,9 @@ layout: doc title: SSL dovecotlinks: + ssl_certificate_requirements: + hash: certificate-requirements + text: SSL certificate requirements secured_connections: hash: secured-connections text: secured connections @@ -409,8 +412,8 @@ protocol !smtp { You may also force the username to be taken from the certificate by setting [[setting,auth_ssl_username_from_cert,yes]]. -* The text is looked up from subject DN's specified field using OpenSSL's - `X509_NAME_get_text_by_NID()` function. +* The text is looked up from subject DN's specified field and converted + to UTF-8. * By default the `CommonName` field is used. @@ -423,6 +426,213 @@ currently circumvents Dovecot's security model so it's not recommended to use it, but it is possible by making the passdb allow logins using any password (typically requiring `nopassword` extra field to be returned). +## Certificate Requirements + +[[added,settings_ssl_cert_requirements_added]] + +[[setting,ssl_server_require_client_cert]] restricts which client +certificates are accepted for incoming connections, and +[[setting,ssl_client_require_server_cert]] which server certificates are +accepted for outgoing connections. Both are [[link,settings_syntax_named_filters, +named list filters]] with the same blocks: + +```doveconf[dovecot.conf] +protocol imap { + ssl_server_require_client_cert x1 { + issuer { + O = Example Corp + CN = Example Client CA + } + subject { + OU = Mail Clients + } + } +} +``` + +* The certificate must satisfy at least one block (OR). Within a block every + key in `issuer` and `subject` must match (AND). +* Each key has one value. For "OU is `a` or `b`" use two blocks: + + ```doveconf[dovecot.conf] + ssl_server_require_client_cert ou-a { + subject { + OU = a + } + } + ssl_server_require_client_cert ou-b { + subject { + OU = b + } + } + ``` + +* Values are matched exactly against the UTF-8 converted DN values, with only + ASCII letters case-folded. No wildcards. This includes `uri` and `email` + values, although the path of a URI and the local part of an email address + are case-sensitive in general. +* A key is satisfied if any entry of that attribute matches. For example + `OU = a` matches a certificate with `OU=a` and `OU=b`. The + [[setting,auth_ssl_username_from_cert]] username uses only the first + entry, so it can differ from the one that satisfied the block. +* Keys are OpenSSL short or long attribute names (`O`, `OU`, `CN`, + `commonName`). They are case-sensitive: `o` is an "unknown attribute" + error when the TLS context is created. +* `CN` also matches a `dNSName` subject alternative name, so a certificate + whose CN differs can still satisfy a `CN` key. `dns`, `email`, `uri` and + `ip` match the corresponding subject alternative name entries. These are + subject-only. +* The issuer is matched by its DN, so list the full issuer DN. Two CAs with + an identical DN can't be told apart. +* Only `%{...}` variables are expanded in values. + +### Incoming Connections + +Blocks are checked only in TLS handshakes and, behind HAProxy, in the PROXY +protocol header. Use [[setting,ssl,required]], or a client can skip TLS: +plaintext connections aren't checked, also those from a plaintext HAProxy +frontend. Blocks are ignored in a scope with [[setting,ssl,no]] for direct +connections; an SNI name that selects such a +[[link,settings_connection_filters,local_name]] scope fails the handshake. +Connections from HAProxy are checked even with `ssl = no`. + +* [[setting,ssl_server_request_client_cert,yes]] is required for direct TLS. + Otherwise creating the TLS context fails with + `ssl_server_require_client_cert requires ssl_server_request_client_cert=yes`, + including with `any-cert`. +* A client with no certificate, or a certificate that satisfies no block, is + rejected in the TLS handshake. +* TLS session resumption is disabled where blocks are set. +* Blocks only inside a [[link,settings_connection_filters,local_name]] + filter apply only to clients that send that SNI name. A client sending no + SNI name, or another one, is checked with the blocks of the scope it ends + up in, possibly none. + +#### HAProxy + +HAProxy doesn't pass on the client certificate, so Dovecot checks what HAProxy +sends in the PROXY protocol v2 header. The same checks apply to +[[setting,haproxy_trusted_networks]] connections of the login processes and +LMTP. When blocks are set: + +* Every HAProxy connection must carry the SSL TLV, so use + `send-proxy-v2-ssl-cn` on the HAProxy `server` line. A connection without it + is rejected: PROXY protocol v1 and v2 without the TLV, also plaintext ones. +* For a TLS client HAProxy must report a verified certificate: a certificate + was sent and HAProxy's verify result is 0. Otherwise the connection is + rejected. + +There are two ways to get the certificate to Dovecot. + +**Distinguished names.** HAProxy sends the subject and issuer DNs in custom +TLVs, and [[setting,haproxy_ssl_client_cert_subject_tlv]] and +[[setting,haproxy_ssl_client_cert_issuer_tlv]] tell Dovecot which TLVs they +are: + +```haproxy[haproxy.cfg] +server s1 10.0.0.1:143 send-proxy-v2 send-proxy-v2-ssl-cn proxy-v2-options authority set-proxy-v2-tlv-fmt(0xE1) %[ssl_c_s_dn(,0,rfc2253)] set-proxy-v2-tlv-fmt(0xE2) %[ssl_c_i_dn(,0,rfc2253)] +``` + +```doveconf[dovecot.conf] +haproxy_ssl_client_cert_subject_tlv = 0xE1 +haproxy_ssl_client_cert_issuer_tlv = 0xE2 +``` + +* The DNs are matched like a certificate in a native TLS handshake, with these + differences: the `dns`, `email`, `uri` and `ip` keys never match, and `CN` + doesn't fall back to a `dNSName` entry. Attributes that HAProxy prints as + `#hex` are ignored. +* Use the `(,0,rfc2253)` form. The default `ssl_c_s_dn` form isn't escaped and + is cut at a NUL byte. A wrong entry name, e.g. `(dn)`, gives an empty value, + which is rejected. +* Both TLVs must be set, with different types in the range `0xE0` to `0xEF`. + A connection without them is rejected, also a plaintext one. +* HAProxy drops the TLVs if the header exceeds its `tune.bufsize`. Dovecot + rejects such a connection. It also reads at most 1500 bytes of a PROXY + header, which a long non-ASCII DN can exceed. + +**Common name only.** Without the DN settings only the subject CN is known +(`send-proxy-v2-ssl-cn`), so only blocks with no `issuer` keys and exactly one +`subject` key, `CN`, `commonName` or `2.5.4.3`, are evaluated, against that +CN. There is no issuer, other attribute or subject alternative name to check, +and no CN to `dNSName` fallback. If no such block matches, the connection is +rejected. A CN with a NUL byte or invalid UTF-8 isn't usable and counts as no +CN. HAProxy forwards the raw bytes of the CN, so one that isn't a UTF8String, +such as a BMPString or T61String, can't be matched. + +For both: + +* Blocks inside a [[link,settings_connection_filters,local_name]] filter need + `proxy-v2-options authority`, because HAProxy sends the client's SNI name + only then. Blocks only under `local_name` are selected by the SNI name on + every path, also for direct TLS: a client without a matching SNI name isn't + checked against them. Set blocks outside the filter to enforce them + regardless of SNI. +* A HAProxy frontend bound to `unix@` or `abns@` sends PROXY v2 `LOCAL` + headers, which carry no client certificate. A TLS client there is rejected, + so use TCP binds. Health checks (`check-send-proxy`) and plaintext + connections pass. + +### Outgoing Connections + +* [[setting,ssl_client_require_valid_cert,yes]] is required. Otherwise + creating the TLS context fails with `ssl_client_require_server_cert + requires ssl_client_require_valid_cert=yes`. +* A server certificate that satisfies no block fails the connection with + `Server certificate does not satisfy any ssl_client_require_server_cert + block`. +* TLS renegotiation is refused on connections with blocks, and a resumed + TLS session is rejected. +* A block that is not inside a filter applies to all outgoing TLS + connections, for example proxying to backends with self-signed + certificates. Scope it with a filter. + +Blocks can be placed inside the filter of the client, including per +namespace: + +```doveconf[dovecot.conf] +namespace shared { + imapc { + host = imap.example.com + ssl = imaps + ssl_client_require_server_cert backend { + subject { + dns = imap.example.com + } + } + } +} +``` + +[[setting,imapc]] and [[setting,pop3c]] connections aren't shared between +namespaces whose blocks differ. + +#### Drivers + +* LDAP: enforced by Dovecot's own TLS for `ldaps://` URIs and + [[setting,ldap_starttls]]. With `ldap://` and no StartTLS the blocks are + ignored. With `ldaps://a ldap://b` and no StartTLS, failover to `b` is + plaintext and unchecked. +* These fail if blocks apply to them: + * MySQL with [[setting,mysql_ssl,yes]]. With `mysql_ssl = no` the blocks + aren't checked. + * Cassandra unless [[setting,cassandra_ssl,no]]. + * PostgreSQL, unless [[setting,pgsql_parameters]] has `sslmode=disable`, or + every host is a unix socket path and `pgsql_parameters` has no `host`, + `hostaddr` or `service` key. The `ssl_*` settings are read only when + blocks exist. + +### Scoping + +Blocks inside nested filters (e.g. `local_name`) add to the blocks inherited +from outer scopes and can't remove them. Re-opening an inherited block's +name in a nested scope merges the keys into that block. Limitations: + +* Re-opening a block without any keys makes `doveconf` fail, although the + configuration is valid. +* Keys that differ only by case in different scopes are caught only at + runtime. + ## Testing Try out your new setup: