#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,221 @@
|
||||
// Package mailauth runs the inbound authentication chain of PLAN.md section
|
||||
// 6.1: SPF over the envelope, DKIM over the message, then DMARC alignment on
|
||||
// top of both.
|
||||
package mailauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
spflib "blitiri.com.ar/go/spf"
|
||||
dkimlib "github.com/emersion/go-msgauth/dkim"
|
||||
dmarclib "github.com/emersion/go-msgauth/dmarc"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// LookupTXTFunc resolves TXT records. It is injected so the chain can be
|
||||
// tested against a fixed zone instead of the live internet.
|
||||
type LookupTXTFunc func(domain string) ([]string, error)
|
||||
|
||||
// Authenticator implements port.MailAuthenticator.
|
||||
type Authenticator struct {
|
||||
// authServID is the name this server puts in the Authentication-Results
|
||||
// header so that downstream consumers can tell our verdicts from a
|
||||
// forwarder's (RFC 8601 section 2.4).
|
||||
authServID string
|
||||
lookupTXT LookupTXTFunc
|
||||
resolver spflib.DNSResolver
|
||||
}
|
||||
|
||||
func NewAuthenticator(authServID string) *Authenticator {
|
||||
if authServID == "" {
|
||||
authServID = "lambdamail"
|
||||
}
|
||||
return &Authenticator{authServID: authServID, lookupTXT: net.LookupTXT, resolver: net.DefaultResolver}
|
||||
}
|
||||
|
||||
// WithLookupTXT overrides DNS resolution, for tests and for pointing the
|
||||
// checks at a validating resolver.
|
||||
func (a *Authenticator) WithLookupTXT(lookup LookupTXTFunc, resolver spflib.DNSResolver) *Authenticator {
|
||||
a.lookupTXT = lookup
|
||||
a.resolver = resolver
|
||||
return a
|
||||
}
|
||||
|
||||
func (a *Authenticator) Authenticate(ctx context.Context, input port.InboundAuthInput) port.InboundAuthResult {
|
||||
result := port.InboundAuthResult{
|
||||
SPF: port.AuthResultNone,
|
||||
DKIM: port.AuthResultNone,
|
||||
DMARC: port.AuthResultNone,
|
||||
}
|
||||
|
||||
spfResult, spfDomain := a.checkSPF(ctx, input)
|
||||
result.SPF = spfResult
|
||||
|
||||
dkimResult, dkimDomains := a.checkDKIM(input.Message)
|
||||
result.DKIM = dkimResult
|
||||
|
||||
fromDomain := headerFromDomain(input.Message)
|
||||
result.DMARC, result.DmarcPolicy = a.checkDMARC(fromDomain, spfResult, spfDomain, dkimResult, dkimDomains)
|
||||
|
||||
result.AuthenticationResults = a.formatAuthResults(result, spfDomain, dkimDomains, fromDomain)
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *Authenticator) checkSPF(ctx context.Context, input port.InboundAuthInput) (result string, domain string) {
|
||||
if input.ClientIP == nil {
|
||||
return port.AuthResultNone, ""
|
||||
}
|
||||
|
||||
// RFC 7208 section 2.4: with an empty envelope sender (a bounce) the
|
||||
// identity checked is the HELO name.
|
||||
sender := input.EnvelopeFrom
|
||||
if sender == "" {
|
||||
sender = "postmaster@" + input.HeloDomain
|
||||
}
|
||||
domain = domainOf(sender)
|
||||
if domain == "" {
|
||||
return port.AuthResultNone, ""
|
||||
}
|
||||
|
||||
options := []spflib.Option{spflib.WithContext(ctx)}
|
||||
if a.resolver != nil {
|
||||
options = append(options, spflib.WithResolver(a.resolver))
|
||||
}
|
||||
|
||||
res, _ := spflib.CheckHostWithSender(input.ClientIP, input.HeloDomain, sender, options...)
|
||||
return string(res), domain
|
||||
}
|
||||
|
||||
func (a *Authenticator) checkDKIM(message []byte) (result string, signingDomains []string) {
|
||||
verifications, err := dkimlib.VerifyWithOptions(bytes.NewReader(message), &dkimlib.VerifyOptions{
|
||||
LookupTXT: a.lookupTXT,
|
||||
// A message carrying hundreds of signatures is a resource-exhaustion
|
||||
// attempt, not a legitimate mail (PLAN.md section 12.6).
|
||||
MaxVerifications: 10,
|
||||
})
|
||||
if err != nil && len(verifications) == 0 {
|
||||
return port.AuthResultPermError, nil
|
||||
}
|
||||
if len(verifications) == 0 {
|
||||
return port.AuthResultNone, nil
|
||||
}
|
||||
|
||||
// One passing signature is enough to authenticate the message; the
|
||||
// domains of the passing ones are what DMARC aligns against.
|
||||
anyPass := false
|
||||
for _, v := range verifications {
|
||||
if v.Err == nil {
|
||||
anyPass = true
|
||||
signingDomains = append(signingDomains, v.Domain)
|
||||
}
|
||||
}
|
||||
if anyPass {
|
||||
return port.AuthResultPass, signingDomains
|
||||
}
|
||||
return port.AuthResultFail, nil
|
||||
}
|
||||
|
||||
func (a *Authenticator) checkDMARC(fromDomain, spfResult, spfDomain, dkimResult string, dkimDomains []string) (result string, policy string) {
|
||||
if fromDomain == "" {
|
||||
return port.AuthResultNone, ""
|
||||
}
|
||||
|
||||
record, err := dmarclib.LookupWithOptions(fromDomain, &dmarclib.LookupOptions{LookupTXT: a.lookupTXT})
|
||||
if err != nil {
|
||||
if dmarclib.IsTempFail(err) {
|
||||
return port.AuthResultTempError, ""
|
||||
}
|
||||
return port.AuthResultNone, ""
|
||||
}
|
||||
policy = string(record.Policy)
|
||||
|
||||
// RFC 7489 section 6.6.2: DMARC passes when SPF or DKIM passes *and* the
|
||||
// authenticated identifier aligns with the From: domain.
|
||||
spfAligned := spfResult == port.AuthResultPass &&
|
||||
aligned(fromDomain, spfDomain, record.SPFAlignment == dmarclib.AlignmentStrict)
|
||||
|
||||
dkimAligned := false
|
||||
if dkimResult == port.AuthResultPass {
|
||||
for _, d := range dkimDomains {
|
||||
if aligned(fromDomain, d, record.DKIMAlignment == dmarclib.AlignmentStrict) {
|
||||
dkimAligned = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if spfAligned || dkimAligned {
|
||||
return port.AuthResultPass, policy
|
||||
}
|
||||
return port.AuthResultFail, policy
|
||||
}
|
||||
|
||||
// aligned implements the identifier alignment of RFC 7489 section 3.1: strict
|
||||
// requires an exact match, relaxed accepts a shared organizational domain.
|
||||
func aligned(fromDomain, authDomain string, strict bool) bool {
|
||||
if authDomain == "" {
|
||||
return false
|
||||
}
|
||||
fromDomain, authDomain = strings.ToLower(fromDomain), strings.ToLower(authDomain)
|
||||
if fromDomain == authDomain {
|
||||
return true
|
||||
}
|
||||
if strict {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(fromDomain, "."+authDomain) || strings.HasSuffix(authDomain, "."+fromDomain)
|
||||
}
|
||||
|
||||
// formatAuthResults renders RFC 8601 section 2.2. The header is written by us
|
||||
// and describes our own checks, so it must always name this server.
|
||||
func (a *Authenticator) formatAuthResults(result port.InboundAuthResult, spfDomain string, dkimDomains []string, fromDomain string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(a.authServID)
|
||||
|
||||
fmt.Fprintf(&sb, ";\r\n\tspf=%s", result.SPF)
|
||||
if spfDomain != "" {
|
||||
fmt.Fprintf(&sb, " smtp.mailfrom=%s", spfDomain)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, ";\r\n\tdkim=%s", result.DKIM)
|
||||
if len(dkimDomains) > 0 {
|
||||
fmt.Fprintf(&sb, " header.d=%s", dkimDomains[0])
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, ";\r\n\tdmarc=%s", result.DMARC)
|
||||
if fromDomain != "" {
|
||||
fmt.Fprintf(&sb, " header.from=%s", fromDomain)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// headerFromDomain extracts the domain of the From: header, which is the
|
||||
// identity DMARC is defined over (RFC 7489 section 6.6.1).
|
||||
func headerFromDomain(message []byte) string {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(message))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
addresses, err := mail.ParseAddressList(msg.Header.Get("From"))
|
||||
if err != nil || len(addresses) == 0 {
|
||||
return ""
|
||||
}
|
||||
return domainOf(addresses[0].Address)
|
||||
}
|
||||
|
||||
func domainOf(address string) string {
|
||||
if idx := strings.LastIndex(address, "@"); idx >= 0 && idx < len(address)-1 {
|
||||
return strings.ToLower(address[idx+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Ensure port interface compliance
|
||||
var _ port.MailAuthenticator = (*Authenticator)(nil)
|
||||
@@ -0,0 +1,243 @@
|
||||
package mailauth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
dkimlib "github.com/emersion/go-msgauth/dkim"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/infrastructure/dkim"
|
||||
)
|
||||
|
||||
// fakeZone answers TXT and address lookups from a fixed map, so the whole
|
||||
// authentication chain runs without touching the real internet.
|
||||
type fakeZone struct {
|
||||
txt map[string][]string
|
||||
a map[string][]string
|
||||
}
|
||||
|
||||
func (z *fakeZone) LookupTXT(_ context.Context, name string) ([]string, error) {
|
||||
return z.lookupTXT(name)
|
||||
}
|
||||
|
||||
func (z *fakeZone) lookupTXT(name string) ([]string, error) {
|
||||
name = strings.TrimSuffix(name, ".")
|
||||
if records, ok := z.txt[name]; ok {
|
||||
return records, nil
|
||||
}
|
||||
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
|
||||
}
|
||||
|
||||
func (z *fakeZone) LookupIPAddr(_ context.Context, name string) ([]net.IPAddr, error) {
|
||||
name = strings.TrimSuffix(name, ".")
|
||||
var out []net.IPAddr
|
||||
for _, ip := range z.a[name] {
|
||||
out = append(out, net.IPAddr{IP: net.ParseIP(ip)})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (z *fakeZone) LookupMX(_ context.Context, name string) ([]*net.MX, error) {
|
||||
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
|
||||
}
|
||||
|
||||
func (z *fakeZone) LookupAddr(_ context.Context, addr string) ([]string, error) {
|
||||
return nil, &net.DNSError{Err: "no such host", Name: addr, IsNotFound: true}
|
||||
}
|
||||
|
||||
func newTestAuthenticator(zone *fakeZone) *Authenticator {
|
||||
return NewAuthenticator("mail.lambdamail.test").WithLookupTXT(zone.lookupTXT, zone)
|
||||
}
|
||||
|
||||
const plainMessage = "From: sender@sender.test\r\n" +
|
||||
"To: rcpt@lambdamail.test\r\n" +
|
||||
"Subject: hello\r\n" +
|
||||
"Date: Mon, 20 Jul 2026 10:00:00 +0000\r\n" +
|
||||
"Message-ID: <m1@sender.test>\r\n" +
|
||||
"\r\n" +
|
||||
"body\r\n"
|
||||
|
||||
// A sender whose SPF authorises the connecting IP, with a DMARC record in
|
||||
// place, must come out as pass/pass on the aligned identifier.
|
||||
func TestAuthenticate_SpfPassAlignsDmarc(t *testing.T) {
|
||||
zone := &fakeZone{
|
||||
txt: map[string][]string{
|
||||
"sender.test": {"v=spf1 ip4:192.0.2.10 -all"},
|
||||
"_dmarc.sender.test": {"v=DMARC1; p=reject"},
|
||||
},
|
||||
}
|
||||
|
||||
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
|
||||
ClientIP: net.ParseIP("192.0.2.10"),
|
||||
HeloDomain: "mx.sender.test",
|
||||
EnvelopeFrom: "sender@sender.test",
|
||||
Message: []byte(plainMessage),
|
||||
})
|
||||
|
||||
if got.SPF != port.AuthResultPass {
|
||||
t.Errorf("SPF = %q, want pass", got.SPF)
|
||||
}
|
||||
if got.DMARC != port.AuthResultPass {
|
||||
t.Errorf("DMARC = %q, want pass (SPF aligned with the From: domain)", got.DMARC)
|
||||
}
|
||||
if got.DmarcPolicy != "reject" {
|
||||
t.Errorf("DmarcPolicy = %q, want reject", got.DmarcPolicy)
|
||||
}
|
||||
if !strings.Contains(got.AuthenticationResults, "spf=pass") ||
|
||||
!strings.Contains(got.AuthenticationResults, "dmarc=pass") {
|
||||
t.Errorf("Authentication-Results does not report the verdicts: %s", got.AuthenticationResults)
|
||||
}
|
||||
if !strings.HasPrefix(got.AuthenticationResults, "mail.lambdamail.test") {
|
||||
t.Errorf("Authentication-Results must name this server first: %s", got.AuthenticationResults)
|
||||
}
|
||||
}
|
||||
|
||||
// An unauthorised IP must fail SPF, and with no DKIM signature to fall back
|
||||
// on, DMARC fails too - which is what lets the reject policy be honoured.
|
||||
func TestAuthenticate_SpfFailWithoutDkimFailsDmarc(t *testing.T) {
|
||||
zone := &fakeZone{
|
||||
txt: map[string][]string{
|
||||
"sender.test": {"v=spf1 ip4:192.0.2.10 -all"},
|
||||
"_dmarc.sender.test": {"v=DMARC1; p=quarantine"},
|
||||
},
|
||||
}
|
||||
|
||||
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
|
||||
ClientIP: net.ParseIP("198.51.100.66"),
|
||||
HeloDomain: "evil.test",
|
||||
EnvelopeFrom: "sender@sender.test",
|
||||
Message: []byte(plainMessage),
|
||||
})
|
||||
|
||||
if got.SPF != port.AuthResultFail {
|
||||
t.Errorf("SPF = %q, want fail", got.SPF)
|
||||
}
|
||||
if got.DMARC != port.AuthResultFail {
|
||||
t.Errorf("DMARC = %q, want fail", got.DMARC)
|
||||
}
|
||||
if got.DmarcPolicy != "quarantine" {
|
||||
t.Errorf("DmarcPolicy = %q, want quarantine", got.DmarcPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// A valid DKIM signature must rescue DMARC even when SPF fails, which is
|
||||
// exactly the forwarding case DKIM exists for.
|
||||
func TestAuthenticate_DkimPassAlignsDmarcDespiteSpfFail(t *testing.T) {
|
||||
generated, err := dkim.Generate(dkim.AlgorithmRSA2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Generate: %v", err)
|
||||
}
|
||||
signer, err := dkim.ParsePrivateKey(generated.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePrivateKey: %v", err)
|
||||
}
|
||||
|
||||
var signed bytes.Buffer
|
||||
err = dkimlib.Sign(&signed, strings.NewReader(plainMessage), &dkimlib.SignOptions{
|
||||
Domain: "sender.test",
|
||||
Selector: "default",
|
||||
Signer: signer,
|
||||
HeaderCanonicalization: dkimlib.CanonicalizationRelaxed,
|
||||
BodyCanonicalization: dkimlib.CanonicalizationRelaxed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
|
||||
zone := &fakeZone{
|
||||
txt: map[string][]string{
|
||||
"sender.test": {"v=spf1 ip4:192.0.2.10 -all"},
|
||||
"_dmarc.sender.test": {"v=DMARC1; p=reject"},
|
||||
"default._domainkey.sender.test": {"v=DKIM1; k=rsa; p=" + generated.PublicKeyBase64},
|
||||
},
|
||||
}
|
||||
|
||||
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
|
||||
ClientIP: net.ParseIP("203.0.113.99"), // forwarder, not in SPF
|
||||
HeloDomain: "forwarder.test",
|
||||
EnvelopeFrom: "sender@sender.test",
|
||||
Message: signed.Bytes(),
|
||||
})
|
||||
|
||||
if got.DKIM != port.AuthResultPass {
|
||||
t.Fatalf("DKIM = %q, want pass", got.DKIM)
|
||||
}
|
||||
if got.SPF == port.AuthResultPass {
|
||||
t.Fatalf("SPF unexpectedly passed; the test cannot prove DKIM rescued DMARC")
|
||||
}
|
||||
if got.DMARC != port.AuthResultPass {
|
||||
t.Errorf("DMARC = %q, want pass via the aligned DKIM signature", got.DMARC)
|
||||
}
|
||||
}
|
||||
|
||||
// A tampered body must invalidate the signature.
|
||||
func TestAuthenticate_TamperedBodyFailsDkim(t *testing.T) {
|
||||
generated, _ := dkim.Generate(dkim.AlgorithmRSA2048)
|
||||
signer, _ := dkim.ParsePrivateKey(generated.PrivateKeyPEM)
|
||||
|
||||
var signed bytes.Buffer
|
||||
dkimlib.Sign(&signed, strings.NewReader(plainMessage), &dkimlib.SignOptions{
|
||||
Domain: "sender.test", Selector: "default", Signer: signer,
|
||||
HeaderCanonicalization: dkimlib.CanonicalizationRelaxed,
|
||||
BodyCanonicalization: dkimlib.CanonicalizationRelaxed,
|
||||
})
|
||||
|
||||
tampered := bytes.Replace(signed.Bytes(), []byte("body"), []byte("evil"), 1)
|
||||
|
||||
zone := &fakeZone{txt: map[string][]string{
|
||||
"default._domainkey.sender.test": {"v=DKIM1; k=rsa; p=" + generated.PublicKeyBase64},
|
||||
}}
|
||||
|
||||
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
|
||||
ClientIP: net.ParseIP("203.0.113.99"),
|
||||
Message: tampered,
|
||||
})
|
||||
|
||||
if got.DKIM != port.AuthResultFail {
|
||||
t.Errorf("DKIM = %q, want fail for a tampered body", got.DKIM)
|
||||
}
|
||||
}
|
||||
|
||||
// A domain publishing no DMARC record yields "none", never a hard failure.
|
||||
func TestAuthenticate_NoDmarcRecordYieldsNone(t *testing.T) {
|
||||
zone := &fakeZone{txt: map[string][]string{"sender.test": {"v=spf1 -all"}}}
|
||||
|
||||
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
|
||||
ClientIP: net.ParseIP("198.51.100.1"),
|
||||
EnvelopeFrom: "sender@sender.test",
|
||||
Message: []byte(plainMessage),
|
||||
})
|
||||
|
||||
if got.DMARC != port.AuthResultNone {
|
||||
t.Errorf("DMARC = %q, want none", got.DMARC)
|
||||
}
|
||||
if got.DmarcPolicy != "" {
|
||||
t.Errorf("DmarcPolicy = %q, want empty", got.DmarcPolicy)
|
||||
}
|
||||
}
|
||||
|
||||
// A bounce arrives with an empty envelope sender; RFC 7208 section 2.4 says
|
||||
// the HELO identity is checked instead, and it must not crash.
|
||||
func TestAuthenticate_EmptyEnvelopeFromChecksHelo(t *testing.T) {
|
||||
zone := &fakeZone{txt: map[string][]string{
|
||||
"mx.sender.test": {"v=spf1 ip4:192.0.2.10 -all"},
|
||||
}}
|
||||
|
||||
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
|
||||
ClientIP: net.ParseIP("192.0.2.10"),
|
||||
HeloDomain: "mx.sender.test",
|
||||
EnvelopeFrom: "",
|
||||
Message: []byte(plainMessage),
|
||||
})
|
||||
|
||||
if got.SPF != port.AuthResultPass {
|
||||
t.Errorf("SPF = %q, want pass against the HELO identity", got.SPF)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user