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
|
|
@ -434,6 +434,29 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: Reverse DNS (PTR record) for the IP address
|
description: Reverse DNS (PTR record) for the IP address
|
||||||
example: "mail.example.com"
|
example: "mail.example.com"
|
||||||
|
tls:
|
||||||
|
$ref: '#/components/schemas/TLSInfo'
|
||||||
|
description: TLS details of the connection for this hop, if encrypted
|
||||||
|
|
||||||
|
TLSInfo:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: TLS protocol version
|
||||||
|
example: "TLSv1.3"
|
||||||
|
cipher:
|
||||||
|
type: string
|
||||||
|
description: Cipher suite used
|
||||||
|
example: "TLS_AES_256_GCM_SHA384"
|
||||||
|
bits:
|
||||||
|
type: integer
|
||||||
|
description: Cipher strength in bits
|
||||||
|
example: 256
|
||||||
|
verified:
|
||||||
|
type: boolean
|
||||||
|
description: Whether the peer certificate was verified/trusted
|
||||||
|
example: true
|
||||||
|
|
||||||
DKIMDomainInfo:
|
DKIMDomainInfo:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -540,6 +563,11 @@ components:
|
||||||
x_ptr:
|
x_ptr:
|
||||||
$ref: '#/components/schemas/XPtrResult'
|
$ref: '#/components/schemas/XPtrResult'
|
||||||
description: X-Ptr result (HELO hostname vs reverse DNS consistency check)
|
description: X-Ptr result (HELO hostname vs reverse DNS consistency check)
|
||||||
|
x_tls:
|
||||||
|
$ref: '#/components/schemas/AuthResult'
|
||||||
|
description: >-
|
||||||
|
Transport TLS encryption of the inbound connection (x-tls).
|
||||||
|
Synthesized from the inbound Received hop when no x-tls header is present.
|
||||||
|
|
||||||
AuthResult:
|
AuthResult:
|
||||||
type: object
|
type: object
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@
|
||||||
|
|
||||||
"PTR" : {},
|
"PTR" : {},
|
||||||
|
|
||||||
|
"TLS" : {},
|
||||||
|
|
||||||
"SenderID" : {
|
"SenderID" : {
|
||||||
"hide_none" : 1
|
"hide_none" : 1
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -36,5 +36,8 @@ smtpd_recipient_restrictions =
|
||||||
permit_mynetworks,
|
permit_mynetworks,
|
||||||
reject_unauth_destination
|
reject_unauth_destination
|
||||||
|
|
||||||
|
# TLS - record the negotiated cipher/protocol in the Received: header
|
||||||
|
smtpd_tls_received_header = yes
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
debug_peer_level = 2
|
debug_peer_level = 2
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,13 @@ func (a *AuthenticationAnalyzer) parseAuthenticationResultsHeader(header string,
|
||||||
results.XPtr = a.parseXPtrResult(part)
|
results.XPtr = a.parseXPtrResult(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse x-tls
|
||||||
|
if strings.HasPrefix(part, "x-tls=") {
|
||||||
|
if results.XTls == nil {
|
||||||
|
results.XTls = a.parseXTLSResult(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -183,6 +190,9 @@ func (a *AuthenticationAnalyzer) CalculateAuthenticationScore(results *model.Aut
|
||||||
// Penalty-only: X-Aligned-From (up to -5 points on failure)
|
// Penalty-only: X-Aligned-From (up to -5 points on failure)
|
||||||
score += 5 * a.calculateXAlignedFromScore(results) / 100
|
score += 5 * a.calculateXAlignedFromScore(results) / 100
|
||||||
|
|
||||||
|
// Penalty-only: X-TLS / transport encryption (-10 points when not encrypted)
|
||||||
|
score += 10 * a.calculateXTLSScore(results) / 100
|
||||||
|
|
||||||
// Ensure score doesn't exceed 100
|
// Ensure score doesn't exceed 100
|
||||||
if score > 100 {
|
if score > 100 {
|
||||||
score = 100
|
score = 100
|
||||||
|
|
|
||||||
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, ", ")
|
||||||
|
}
|
||||||
165
pkg/analyzer/authentication_x_tls_test.go
Normal file
165
pkg/analyzer/authentication_x_tls_test.go
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
// 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 (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDeliver/internal/model"
|
||||||
|
"git.happydns.org/happyDeliver/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseXTLSResult(t *testing.T) {
|
||||||
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
|
result := analyzer.parseXTLSResult("x-tls=pass smtp.version=TLSv1.3 smtp.cipher=TLS_AES_256_GCM_SHA384 smtp.bits=256")
|
||||||
|
|
||||||
|
if result.Result != model.AuthResultResultPass {
|
||||||
|
t.Errorf("Result = %v, want pass", result.Result)
|
||||||
|
}
|
||||||
|
if result.Details == nil {
|
||||||
|
t.Fatal("Details should not be nil")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"TLSv1.3", "TLS_AES_256_GCM_SHA384", "256 bits"} {
|
||||||
|
if !strings.Contains(*result.Details, want) {
|
||||||
|
t.Errorf("Details %q should contain %q", *result.Details, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCalculateXTLSScore(t *testing.T) {
|
||||||
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
xtls *model.AuthResult
|
||||||
|
score int
|
||||||
|
}{
|
||||||
|
{"nil", nil, 0},
|
||||||
|
{"pass", &model.AuthResult{Result: model.AuthResultResultPass}, 0},
|
||||||
|
{"none", &model.AuthResult{Result: model.AuthResultResultNone}, -100},
|
||||||
|
{"fail", &model.AuthResult{Result: model.AuthResultResultFail}, -100},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
results := &model.AuthenticationResults{XTls: tt.xtls}
|
||||||
|
if got := analyzer.calculateXTLSScore(results); got != tt.score {
|
||||||
|
t.Errorf("calculateXTLSScore = %d, want %d", got, tt.score)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReconcileXTLS(t *testing.T) {
|
||||||
|
analyzer := NewAuthenticationAnalyzer("")
|
||||||
|
|
||||||
|
t.Run("keeps existing x-tls header result", func(t *testing.T) {
|
||||||
|
existing := &model.AuthResult{Result: model.AuthResultResultFail}
|
||||||
|
results := &model.AuthenticationResults{XTls: existing}
|
||||||
|
chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{Version: utils.PtrTo("TLSv1.3")}}}
|
||||||
|
analyzer.ReconcileXTLS(results, chain)
|
||||||
|
if results.XTls != existing {
|
||||||
|
t.Error("existing XTls should be preserved")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("synthesizes pass from encrypted inbound hop", func(t *testing.T) {
|
||||||
|
results := &model.AuthenticationResults{}
|
||||||
|
chain := &[]model.ReceivedHop{{Tls: &model.TLSInfo{
|
||||||
|
Version: utils.PtrTo("TLSv1.3"),
|
||||||
|
Cipher: utils.PtrTo("TLS_AES_256_GCM_SHA384"),
|
||||||
|
Bits: utils.PtrTo(256),
|
||||||
|
}}}
|
||||||
|
analyzer.ReconcileXTLS(results, chain)
|
||||||
|
if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass {
|
||||||
|
t.Fatalf("expected synthesized pass, got %+v", results.XTls)
|
||||||
|
}
|
||||||
|
if results.XTls.Details == nil || !strings.Contains(*results.XTls.Details, "TLSv1.3") {
|
||||||
|
t.Errorf("details should mention TLS version, got %v", results.XTls.Details)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("synthesizes pass from ESMTPS protocol without TLS parenthetical", func(t *testing.T) {
|
||||||
|
// smtpd_tls_received_header disabled: no TLS details, but ESMTPS proves encryption.
|
||||||
|
results := &model.AuthenticationResults{}
|
||||||
|
chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTPS")}}
|
||||||
|
analyzer.ReconcileXTLS(results, chain)
|
||||||
|
if results.XTls == nil || results.XTls.Result != model.AuthResultResultPass {
|
||||||
|
t.Fatalf("expected synthesized pass, got %+v", results.XTls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("synthesizes none from plaintext ESMTP protocol", func(t *testing.T) {
|
||||||
|
results := &model.AuthenticationResults{}
|
||||||
|
chain := &[]model.ReceivedHop{{With: utils.PtrTo("ESMTP")}}
|
||||||
|
analyzer.ReconcileXTLS(results, chain)
|
||||||
|
if results.XTls == nil || results.XTls.Result != model.AuthResultResultNone {
|
||||||
|
t.Fatalf("expected synthesized none, got %+v", results.XTls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("leaves nil when neither TLS info nor protocol is known", func(t *testing.T) {
|
||||||
|
results := &model.AuthenticationResults{}
|
||||||
|
chain := &[]model.ReceivedHop{{}}
|
||||||
|
analyzer.ReconcileXTLS(results, chain)
|
||||||
|
if results.XTls != nil {
|
||||||
|
t.Errorf("expected nil XTls when undetermined, got %+v", results.XTls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("leaves nil with empty chain", func(t *testing.T) {
|
||||||
|
results := &model.AuthenticationResults{}
|
||||||
|
analyzer.ReconcileXTLS(results, &[]model.ReceivedHop{})
|
||||||
|
if results.XTls != nil {
|
||||||
|
t.Errorf("expected nil XTls, got %+v", results.XTls)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtocolIndicatesTLS(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
with string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"ESMTPS", true},
|
||||||
|
{"ESMTPSA", true},
|
||||||
|
{"SMTPS", true},
|
||||||
|
{"LMTPS", true},
|
||||||
|
{"LMTPSA", true},
|
||||||
|
{"SMTP", false},
|
||||||
|
{"ESMTP", false},
|
||||||
|
{"ESMTPA", false},
|
||||||
|
{"LMTP", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.with, func(t *testing.T) {
|
||||||
|
if got := protocolIndicatesTLS(utils.PtrTo(tt.with)); got != tt.want {
|
||||||
|
t.Errorf("protocolIndicatesTLS(%q) = %v, want %v", tt.with, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if protocolIndicatesTLS(nil) {
|
||||||
|
t.Error("protocolIndicatesTLS(nil) should be false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -693,5 +694,50 @@ func (h *HeaderAnalyzer) parseReceivedHeader(receivedValue string) *model.Receiv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract TLS details from the Received header parentheticals
|
||||||
|
// (e.g. "(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) ...)")
|
||||||
|
hop.Tls = parseReceivedTLS(normalized)
|
||||||
|
|
||||||
return hop
|
return hop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parseReceivedTLS extracts TLS connection details from a normalized Received header value.
|
||||||
|
// Returns nil when the hop was not encrypted (no TLS version/cipher found).
|
||||||
|
func parseReceivedTLS(normalized string) *model.TLSInfo {
|
||||||
|
tls := &model.TLSInfo{}
|
||||||
|
found := false
|
||||||
|
|
||||||
|
// TLS protocol version, e.g. "using TLSv1.3"
|
||||||
|
if matches := regexp.MustCompile(`(?i)using\s+(TLSv[0-9.]+|SSLv[0-9.]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
||||||
|
tls.Version = &matches[1]
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cipher suite, e.g. "with cipher TLS_AES_256_GCM_SHA384"
|
||||||
|
if matches := regexp.MustCompile(`(?i)with cipher\s+([A-Za-z0-9_-]+)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
||||||
|
tls.Cipher = &matches[1]
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cipher strength, e.g. "(256/256 bits)"
|
||||||
|
if matches := regexp.MustCompile(`\((\d+)/\d+ bits\)`).FindStringSubmatch(normalized); len(matches) > 1 {
|
||||||
|
if bits, err := strconv.Atoi(matches[1]); err == nil {
|
||||||
|
tls.Bits = &bits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificate verification status. Postfix emits "(verified OK)" when the peer
|
||||||
|
// certificate was trusted, "(not verified)" otherwise. "No client certificate
|
||||||
|
// requested" leaves the field unset (trust is simply not applicable).
|
||||||
|
if regexp.MustCompile(`(?i)verified OK`).MatchString(normalized) {
|
||||||
|
tls.Verified = utils.PtrTo(true)
|
||||||
|
} else if regexp.MustCompile(`(?i)not verified`).MatchString(normalized) {
|
||||||
|
tls.Verified = utils.PtrTo(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tls
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -677,6 +677,77 @@ func TestParseReceivedHeader(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseReceivedTLS(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
receivedValue string
|
||||||
|
expectNil bool
|
||||||
|
expectVersion *string
|
||||||
|
expectCipher *string
|
||||||
|
expectBits *int
|
||||||
|
expectVerified *bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "TLS 1.3 no client certificate",
|
||||||
|
receivedValue: "from mail.example.com (unknown [IPv6:2001:db8::1]) " +
|
||||||
|
"(using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) " +
|
||||||
|
"key-exchange x25519 server-signature ECDSA (prime256v1) server-digest SHA256) " +
|
||||||
|
"(No client certificate requested) " +
|
||||||
|
"by mx.example.org (Postfix) with ESMTPSA id 1EFD11611EA; Sun, 19 Oct 2025 09:40:33 +0000 (UTC)",
|
||||||
|
expectVersion: strPtr("TLSv1.3"),
|
||||||
|
expectCipher: strPtr("TLS_AES_256_GCM_SHA384"),
|
||||||
|
expectBits: intPtr(256),
|
||||||
|
expectVerified: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TLS with verified client certificate",
|
||||||
|
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) " +
|
||||||
|
"(using TLSv1.2 with cipher ECDHE-RSA-AES128-GCM-SHA256 (128/128 bits)) " +
|
||||||
|
"(Client CN \"example\", Issuer \"CA\" (verified OK)) " +
|
||||||
|
"by mx.receiver.com (Postfix) with ESMTPS id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||||
|
expectVersion: strPtr("TLSv1.2"),
|
||||||
|
expectCipher: strPtr("ECDHE-RSA-AES128-GCM-SHA256"),
|
||||||
|
expectBits: intPtr(128),
|
||||||
|
expectVerified: boolPtr(true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Plaintext (no TLS)",
|
||||||
|
receivedValue: "from mail.example.com (mail.example.com [192.0.2.1]) by mx.receiver.com (Postfix) with ESMTP id ABC; Mon, 01 Jan 2024 12:00:00 +0000",
|
||||||
|
expectNil: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
normalized := strings.Join(strings.Fields(tt.receivedValue), " ")
|
||||||
|
tls := parseReceivedTLS(normalized)
|
||||||
|
|
||||||
|
if tt.expectNil {
|
||||||
|
if tls != nil {
|
||||||
|
t.Fatalf("expected nil TLS info, got %+v", tls)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if tls == nil {
|
||||||
|
t.Fatal("parseReceivedTLS returned nil")
|
||||||
|
}
|
||||||
|
if !equalStrPtr(tls.Version, tt.expectVersion) {
|
||||||
|
t.Errorf("Version = %v, want %v", ptrToStr(tls.Version), ptrToStr(tt.expectVersion))
|
||||||
|
}
|
||||||
|
if !equalStrPtr(tls.Cipher, tt.expectCipher) {
|
||||||
|
t.Errorf("Cipher = %v, want %v", ptrToStr(tls.Cipher), ptrToStr(tt.expectCipher))
|
||||||
|
}
|
||||||
|
if (tls.Bits == nil) != (tt.expectBits == nil) || (tls.Bits != nil && *tls.Bits != *tt.expectBits) {
|
||||||
|
t.Errorf("Bits = %v, want %v", tls.Bits, tt.expectBits)
|
||||||
|
}
|
||||||
|
if (tls.Verified == nil) != (tt.expectVerified == nil) || (tls.Verified != nil && *tls.Verified != *tt.expectVerified) {
|
||||||
|
t.Errorf("Verified = %v, want %v", tls.Verified, tt.expectVerified)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
|
func TestGenerateHeaderAnalysis_WithReceivedChain(t *testing.T) {
|
||||||
analyzer := NewHeaderAnalyzer()
|
analyzer := NewHeaderAnalyzer()
|
||||||
|
|
||||||
|
|
@ -908,6 +979,10 @@ func strPtr(s string) *string {
|
||||||
return &s
|
return &s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool {
|
||||||
|
return &b
|
||||||
|
}
|
||||||
|
|
||||||
func ptrToStr(p *string) string {
|
func ptrToStr(p *string) string {
|
||||||
if p == nil {
|
if p == nil {
|
||||||
return "<nil>"
|
return "<nil>"
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,10 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults {
|
||||||
// Run all analyzers
|
// Run all analyzers
|
||||||
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email)
|
||||||
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication)
|
||||||
|
// Fall back to the received chain's inbound TLS when no x-tls header was present.
|
||||||
|
if results.Authentication != nil && results.Headers != nil {
|
||||||
|
r.authAnalyzer.ReconcileXTLS(results.Authentication, results.Headers.ReceivedChain)
|
||||||
|
}
|
||||||
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers)
|
||||||
results.RBL = r.rblChecker.CheckEmail(email)
|
results.RBL = r.rblChecker.CheckEmail(email)
|
||||||
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
results.DNSWL = r.dnswlChecker.CheckEmail(email)
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,40 @@
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- X-TLS (Transport encryption) -->
|
||||||
|
{#if authentication.x_tls}
|
||||||
|
<div class="list-group-item" id="authentication-x-tls">
|
||||||
|
<div class="d-flex align-items-start">
|
||||||
|
<i
|
||||||
|
class="bi {getAuthResultIcon(
|
||||||
|
authentication.x_tls.result,
|
||||||
|
true,
|
||||||
|
)} {getAuthResultClass(authentication.x_tls.result, true)} me-2 fs-5"
|
||||||
|
></i>
|
||||||
|
<div>
|
||||||
|
<strong>Transport TLS</strong>
|
||||||
|
<i
|
||||||
|
class="bi bi-info-circle text-muted ms-1"
|
||||||
|
title="Whether the inbound connection that delivered this message used TLS encryption (x-tls). Falls back to the inbound Received hop when no x-tls header is present."
|
||||||
|
></i>
|
||||||
|
<span
|
||||||
|
class="text-uppercase ms-2 {getAuthResultClass(
|
||||||
|
authentication.x_tls.result,
|
||||||
|
true,
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{authentication.x_tls.result}
|
||||||
|
</span>
|
||||||
|
{#if authentication.x_tls.details}
|
||||||
|
<div class="small text-muted mt-1">
|
||||||
|
{authentication.x_tls.details}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- SPF (Required) -->
|
<!-- SPF (Required) -->
|
||||||
<div class="list-group-item">
|
<div class="list-group-item">
|
||||||
<div class="d-flex align-items-start" id="authentication-spf">
|
<div class="d-flex align-items-start" id="authentication-spf">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,21 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
let { receivedChain }: Props = $props();
|
let { receivedChain }: Props = $props();
|
||||||
|
|
||||||
|
// Mirror of the backend protocolIndicatesTLS (RFC 3848): the transport keyword
|
||||||
|
// gains a trailing "S" when TLS was used (ESMTPS, ESMTPSA, SMTPS, LMTPS, LMTPSA...).
|
||||||
|
function protocolIndicatesTLS(withProto: string | undefined | null): boolean {
|
||||||
|
if (!withProto) return false;
|
||||||
|
const p = withProto.trim().toUpperCase();
|
||||||
|
return p.endsWith("S") || p.endsWith("SA");
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 3848: a trailing "A" means the sender authenticated (SMTP AUTH):
|
||||||
|
// ESMTPA, ESMTPSA, LMTPA, LMTPSA...
|
||||||
|
function protocolIndicatesAuth(withProto: string | undefined | null): boolean {
|
||||||
|
if (!withProto) return false;
|
||||||
|
return withProto.trim().toUpperCase().endsWith("A");
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if receivedChain && receivedChain.length > 0}
|
{#if receivedChain && receivedChain.length > 0}
|
||||||
|
|
@ -60,6 +75,63 @@
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<p class="mb-0 small d-flex flex-wrap align-items-center gap-3">
|
||||||
|
{#if hop.tls}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-lock-fill me-1"></i>TLS
|
||||||
|
</span>
|
||||||
|
{#if hop.tls.version}
|
||||||
|
<span>
|
||||||
|
<span class="text-muted">Version:</span>
|
||||||
|
<code>{hop.tls.version}</code>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if hop.tls.cipher}
|
||||||
|
<span>
|
||||||
|
<span class="text-muted">Cipher:</span>
|
||||||
|
<code>{hop.tls.cipher}</code>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if hop.tls.bits}
|
||||||
|
<span>
|
||||||
|
<span class="text-muted">Strength:</span>
|
||||||
|
<code>{hop.tls.bits} bits</code>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if hop.tls.verified !== undefined}
|
||||||
|
<span
|
||||||
|
class:text-success={hop.tls.verified}
|
||||||
|
class:text-warning={!hop.tls.verified}
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
class="bi {hop.tls.verified
|
||||||
|
? 'bi-patch-check-fill'
|
||||||
|
: 'bi-patch-exclamation-fill'} me-1"
|
||||||
|
></i>
|
||||||
|
{hop.tls.verified
|
||||||
|
? "Certificate trusted"
|
||||||
|
: "Certificate not trusted"}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{:else if protocolIndicatesTLS(hop.with)}
|
||||||
|
<span class="badge bg-success">
|
||||||
|
<i class="bi bi-lock-fill me-1"></i>TLS
|
||||||
|
</span>
|
||||||
|
{:else if hop.with}
|
||||||
|
<span class="badge bg-secondary">
|
||||||
|
<i class="bi bi-unlock me-1"></i>No TLS
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge bg-light text-muted border">
|
||||||
|
<i class="bi bi-question-circle me-1"></i>TLS unknown
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if protocolIndicatesAuth(hop.with)}
|
||||||
|
<span class="badge bg-info">
|
||||||
|
<i class="bi bi-person-check-fill me-1"></i>Authenticated
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue