#17: implement DANE TLSA, MTA-STS policy resolution, ARC sealing and mail authentication
This commit is contained in:
1 parent
df8b809683
commit
bd0af98076
22 files changed
+3296
No files matched your search
@@ -0,0 +1,309 @@
|
||||
package usecase
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
)
|
||||
|
||||
// fakeRelay serves one SMTP conversation with STARTTLS and AUTH, recording
|
||||
// what the client sent.
|
||||
type fakeRelay struct {
|
||||
addr string
|
||||
authSeen chan string
|
||||
mailFrom chan string
|
||||
offerTLS bool
|
||||
certificate tls.Certificate
|
||||
}
|
||||
|
||||
func startFakeRelay(t *testing.T, offerTLS bool) *fakeRelay {
|
||||
t.Helper()
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
|
||||
relay := &fakeRelay{
|
||||
addr: ln.Addr().String(),
|
||||
authSeen: make(chan string, 1),
|
||||
mailFrom: make(chan string, 1),
|
||||
offerTLS: offerTLS,
|
||||
}
|
||||
if offerTLS {
|
||||
relay.certificate = relayTestCertificate(t)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go relay.serve(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
return relay
|
||||
}
|
||||
|
||||
func (f *fakeRelay) serve(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
|
||||
r := bufio.NewReader(conn)
|
||||
conn.Write([]byte("220 relay.test ESMTP\r\n"))
|
||||
|
||||
for {
|
||||
line, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.ToUpper(strings.TrimSpace(line))
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(cmd, "EHLO"):
|
||||
if f.offerTLS {
|
||||
conn.Write([]byte("250-relay.test\r\n250-STARTTLS\r\n250 AUTH PLAIN LOGIN\r\n"))
|
||||
} else {
|
||||
conn.Write([]byte("250-relay.test\r\n250 AUTH PLAIN LOGIN\r\n"))
|
||||
}
|
||||
|
||||
case cmd == "STARTTLS":
|
||||
conn.Write([]byte("220 Ready to start TLS\r\n"))
|
||||
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{f.certificate}})
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
conn = tlsConn
|
||||
r = bufio.NewReader(conn)
|
||||
|
||||
case strings.HasPrefix(cmd, "AUTH PLAIN"):
|
||||
payload := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line)[len("AUTH PLAIN"):], " "))
|
||||
if decoded, err := base64.StdEncoding.DecodeString(payload); err == nil {
|
||||
select {
|
||||
case f.authSeen <- strings.ReplaceAll(string(decoded), "\x00", "|"):
|
||||
default:
|
||||
}
|
||||
}
|
||||
conn.Write([]byte("235 2.7.0 Authentication successful\r\n"))
|
||||
|
||||
case strings.HasPrefix(cmd, "MAIL FROM"):
|
||||
select {
|
||||
case f.mailFrom <- strings.TrimSpace(line):
|
||||
default:
|
||||
}
|
||||
conn.Write([]byte("250 Ok\r\n"))
|
||||
|
||||
case cmd == "DATA":
|
||||
conn.Write([]byte("354 Send it\r\n"))
|
||||
for {
|
||||
body, err := r.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body) == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.Write([]byte("250 2.0.0 Queued\r\n"))
|
||||
|
||||
case cmd == "QUIT":
|
||||
conn.Write([]byte("221 Bye\r\n"))
|
||||
return
|
||||
|
||||
default:
|
||||
conn.Write([]byte("250 Ok\r\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func relayWorker(t *testing.T, relay RelayConfig) (*OutboundWorkerUseCase, *fakeOutboundRepo) {
|
||||
t.Helper()
|
||||
|
||||
repo := &fakeOutboundRepo{}
|
||||
repo.Enqueue(context.Background(), newTestJob())
|
||||
|
||||
worker := NewOutboundWorkerUseCase(
|
||||
repo,
|
||||
// A resolver that would fail loudly if it were consulted: with a
|
||||
// relay configured, MX resolution must not happen at all.
|
||||
&fakeMXResolver{hosts: []string{"127.0.0.1:1"}},
|
||||
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
|
||||
nil, nil, "mail.local",
|
||||
)
|
||||
worker.SetRelay(relay)
|
||||
return worker, repo
|
||||
}
|
||||
|
||||
// PLAN.md section 10.4: with a smarthost every message leaves through the
|
||||
// relay, whatever the destination domain is.
|
||||
func TestOutboundWorker_DeliversThroughRelayInsteadOfMX(t *testing.T) {
|
||||
relay := startFakeRelay(t, true)
|
||||
host, portStr, _ := net.SplitHostPort(relay.addr)
|
||||
|
||||
worker, repo := relayWorker(t, RelayConfig{
|
||||
Host: host,
|
||||
Port: atoiOrZero(portStr),
|
||||
Username: "relay-user",
|
||||
Password: "relay-pass",
|
||||
RootCAs: relayTestRoots,
|
||||
})
|
||||
|
||||
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
|
||||
t.Fatalf("ProcessBatch: %v", err)
|
||||
}
|
||||
|
||||
job := repo.jobs[0]
|
||||
if job.Status != entity.OutboundJobStatusDelivered {
|
||||
t.Fatalf("status = %s, want DELIVERED (last error: %s)", job.Status, job.LastError)
|
||||
}
|
||||
if job.TlsPolicyUsed != entity.TLSModeRelay {
|
||||
t.Errorf("TlsPolicyUsed = %q, want relay", job.TlsPolicyUsed)
|
||||
}
|
||||
|
||||
select {
|
||||
case credentials := <-relay.authSeen:
|
||||
if !strings.Contains(credentials, "relay-user") || !strings.Contains(credentials, "relay-pass") {
|
||||
t.Errorf("relay credentials = %q", credentials)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("the worker never authenticated against the relay")
|
||||
}
|
||||
|
||||
select {
|
||||
case from := <-relay.mailFrom:
|
||||
if !strings.Contains(from, "user@domain.test") {
|
||||
t.Errorf("MAIL FROM = %q, want the original envelope sender", from)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Error("the relay never received MAIL FROM")
|
||||
}
|
||||
}
|
||||
|
||||
// Sending the relay credentials over a cleartext link would leak them; the
|
||||
// message is deferred instead.
|
||||
func TestOutboundWorker_RefusesRelayCredentialsWithoutStartTLS(t *testing.T) {
|
||||
relay := startFakeRelay(t, false)
|
||||
host, portStr, _ := net.SplitHostPort(relay.addr)
|
||||
|
||||
worker, repo := relayWorker(t, RelayConfig{
|
||||
Host: host, Port: atoiOrZero(portStr),
|
||||
Username: "relay-user", Password: "relay-pass",
|
||||
})
|
||||
|
||||
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
|
||||
t.Fatalf("ProcessBatch: %v", err)
|
||||
}
|
||||
|
||||
job := repo.jobs[0]
|
||||
if job.Status != entity.OutboundJobStatusDeferred {
|
||||
t.Errorf("status = %s, want DEFERRED", job.Status)
|
||||
}
|
||||
if !strings.Contains(job.LastError, "STARTTLS") {
|
||||
t.Errorf("LastError = %q, want it to name the missing STARTTLS", job.LastError)
|
||||
}
|
||||
}
|
||||
|
||||
// An unreachable relay defers rather than bounces: the relay may come back.
|
||||
func TestOutboundWorker_UnreachableRelayDefers(t *testing.T) {
|
||||
worker, repo := relayWorker(t, RelayConfig{Host: "127.0.0.1", Port: 1})
|
||||
|
||||
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
|
||||
t.Fatalf("ProcessBatch: %v", err)
|
||||
}
|
||||
|
||||
if job := repo.jobs[0]; job.Status != entity.OutboundJobStatusDeferred {
|
||||
t.Errorf("status = %s, want DEFERRED", job.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelayConfig_AddressDefaultsToSubmissionPort(t *testing.T) {
|
||||
if got := (RelayConfig{Host: "relay.example.net"}).Address(); got != "relay.example.net:587" {
|
||||
t.Errorf("Address = %q, want relay.example.net:587", got)
|
||||
}
|
||||
if got := (RelayConfig{Host: "relay.example.net", Port: 2525}).Address(); got != "relay.example.net:2525" {
|
||||
t.Errorf("Address = %q, want relay.example.net:2525", got)
|
||||
}
|
||||
}
|
||||
|
||||
// PLAN.md section 10.4: the published SPF must authorise the relay, otherwise
|
||||
// the hard fail rejects everything the relay forwards.
|
||||
func TestRelayConfig_SpfInclude(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"smtp.sendgrid.net": "include:sendgrid.net",
|
||||
"relay.example.net": "include:example.net",
|
||||
"example.net": "include:example.net",
|
||||
"": "",
|
||||
}
|
||||
|
||||
for host, want := range cases {
|
||||
if got := (RelayConfig{Host: host}).SpfInclude(); got != want {
|
||||
t.Errorf("SpfInclude(%q) = %q, want %q", host, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func atoiOrZero(s string) int {
|
||||
value := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0
|
||||
}
|
||||
value = value*10 + int(c-'0')
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// relayTestCertificate issues a certificate for 127.0.0.1 and registers it as
|
||||
// a trusted root for the test, so the relay connection is verified for real
|
||||
// rather than with verification switched off.
|
||||
func relayTestCertificate(t *testing.T) tls.Certificate {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "relay.test"},
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
DNSNames: []string{"127.0.0.1", "relay.test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
parsed, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
|
||||
relayTestRoots = x509.NewCertPool()
|
||||
relayTestRoots.AddCert(parsed)
|
||||
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
|
||||
}
|
||||
|
||||
// relayTestRoots holds the CA pool for the certificate issued above.
|
||||
var relayTestRoots *x509.CertPool
|
||||
Reference in new issue
Block a user