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
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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue