tls: surface transport TLS status in email path and authentication
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
Parse TLS details (version, cipher, bits, cert verification) from the Postfix Received header parenthetical and expose them per hop, rendered as a per-hop badge in the Email Path card. Add an x-tls Authentication-Results result: parse it when present, and otherwise synthesize it from the inbound hop's TLS info. A negative result (unencrypted inbound connection) applies a -10 authentication score penalty and is shown in the Authentication card. Enable the TLS handler in authentication_milter. Closes: #40
This commit is contained in:
parent
8e7e56851b
commit
d53c1b1e00
11 changed files with 593 additions and 0 deletions
154
pkg/analyzer/authentication_x_tls.go
Normal file
154
pkg/analyzer/authentication_x_tls.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// This file is part of the happyDeliver (R) project.
|
||||
// Copyright (c) 2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package analyzer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDeliver/internal/model"
|
||||
"git.happydns.org/happyDeliver/internal/utils"
|
||||
)
|
||||
|
||||
// parseXTLSResult parses the x-tls result from Authentication-Results.
|
||||
// Example: x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256
|
||||
func (a *AuthenticationAnalyzer) parseXTLSResult(part string) *model.AuthResult {
|
||||
result := &model.AuthResult{}
|
||||
|
||||
// Extract result (pass, fail, none, ...)
|
||||
re := regexp.MustCompile(`x-tls=(\w+)`)
|
||||
if matches := re.FindStringSubmatch(part); len(matches) > 1 {
|
||||
result.Result = model.AuthResultResult(strings.ToLower(matches[1]))
|
||||
}
|
||||
|
||||
result.Details = utils.PtrTo(formatTLSDetails(
|
||||
submatch(part, `smtp\.version=([^\s;()]+)`),
|
||||
submatch(part, `smtp\.cipher=([^\s;()]+)`),
|
||||
submatch(part, `smtp\.bits=(\d+)`),
|
||||
))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// calculateXTLSScore returns a penalty for a negative transport-TLS result.
|
||||
// pass (or absent) does not alter the score; any other result is penalized.
|
||||
func (a *AuthenticationAnalyzer) calculateXTLSScore(results *model.AuthenticationResults) (score int) {
|
||||
if results.XTls != nil {
|
||||
switch results.XTls.Result {
|
||||
case model.AuthResultResultPass:
|
||||
// pass: don't alter the score
|
||||
default:
|
||||
return -100
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// ReconcileXTLS fills in the x-tls result from the inbound connection's parsed TLS
|
||||
// information when no x-tls Authentication-Results header was present. The inbound
|
||||
// connection is the most recent hop (index 0) of the received chain.
|
||||
func (a *AuthenticationAnalyzer) ReconcileXTLS(results *model.AuthenticationResults, chain *[]model.ReceivedHop) {
|
||||
if results == nil || results.XTls != nil {
|
||||
return
|
||||
}
|
||||
if chain == nil || len(*chain) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
inbound := (*chain)[0]
|
||||
switch {
|
||||
case inbound.Tls != nil:
|
||||
// Full TLS parenthetical present (smtpd_tls_received_header = yes).
|
||||
var version, cipher, bits string
|
||||
if inbound.Tls.Version != nil {
|
||||
version = *inbound.Tls.Version
|
||||
}
|
||||
if inbound.Tls.Cipher != nil {
|
||||
cipher = *inbound.Tls.Cipher
|
||||
}
|
||||
if inbound.Tls.Bits != nil {
|
||||
bits = fmt.Sprintf("%d", *inbound.Tls.Bits)
|
||||
}
|
||||
results.XTls = &model.AuthResult{
|
||||
Result: model.AuthResultResultPass,
|
||||
Details: utils.PtrTo(formatTLSDetails(version, cipher, bits)),
|
||||
}
|
||||
|
||||
case protocolIndicatesTLS(inbound.With):
|
||||
// No TLS parenthetical (smtpd_tls_received_header may be disabled), but the
|
||||
// transport keyword (ESMTPS, ESMTPSA, ...) tells us the session was encrypted.
|
||||
// We just don't have the cipher details.
|
||||
results.XTls = &model.AuthResult{
|
||||
Result: model.AuthResultResultPass,
|
||||
Details: utils.PtrTo(fmt.Sprintf("Encrypted connection (%s); cipher details unavailable", *inbound.With)),
|
||||
}
|
||||
|
||||
case inbound.With != nil:
|
||||
// A plaintext transport keyword (SMTP, ESMTP, ESMTPA, ...) is positive
|
||||
// evidence the inbound connection was not encrypted.
|
||||
results.XTls = &model.AuthResult{
|
||||
Result: model.AuthResultResultNone,
|
||||
Details: utils.PtrTo(fmt.Sprintf("Inbound connection was not encrypted (%s)", *inbound.With)),
|
||||
}
|
||||
|
||||
default:
|
||||
// Neither TLS details nor a transport keyword: we cannot tell whether the
|
||||
// connection was encrypted. Leave x-tls unset rather than wrongly penalize.
|
||||
}
|
||||
}
|
||||
|
||||
// protocolIndicatesTLS reports whether an SMTP "with" transport keyword denotes a
|
||||
// TLS-encrypted session. Per RFC 3848 the keyword gains a trailing "S" when STARTTLS
|
||||
// (or implicit TLS) was negotiated: ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA, UTF8SMTPS...
|
||||
// The plaintext variants end in "P" (SMTP, ESMTP, LMTP) or "A" (ESMTPA, LMTPA).
|
||||
func protocolIndicatesTLS(with *string) bool {
|
||||
if with == nil {
|
||||
return false
|
||||
}
|
||||
p := strings.ToUpper(strings.TrimSpace(*with))
|
||||
return strings.HasSuffix(p, "S") || strings.HasSuffix(p, "SA")
|
||||
}
|
||||
|
||||
// submatch returns the first capture group of pattern in s, or "".
|
||||
func submatch(s, pattern string) string {
|
||||
if matches := regexp.MustCompile(pattern).FindStringSubmatch(s); len(matches) > 1 {
|
||||
return matches[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatTLSDetails builds a human-readable summary of the TLS parameters.
|
||||
func formatTLSDetails(version, cipher, bits string) string {
|
||||
var parts []string
|
||||
if version != "" {
|
||||
parts = append(parts, version)
|
||||
}
|
||||
if cipher != "" {
|
||||
parts = append(parts, "cipher "+cipher)
|
||||
}
|
||||
if bits != "" {
|
||||
parts = append(parts, bits+" bits")
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue