Every message was resolved through MX and delivered over SMTP, including one addressed to a mailbox on this very host. Two users on the same server could not write to each other without a working outbound port 25 - which the provider blocks, so on this deployment they could not write to each other at all. It also made internal mail take a pointless round trip through the public internet. The worker now checks whether the recipient resolves to a local mailbox and, if so, hands the message to the inbound path directly. The check runs ahead of the relay branch on purpose: sending local mail out to a smarthost only to have it delivered back is slower and has more ways to fail. The shortcut is narrow. It relies on ResolveDeliveryTargets, which only resolves addresses on an active local domain, and a test covers that a remote recipient still goes out over SMTP - otherwise local delivery would swallow mail meant for the internet. A resolver error is treated as retry rather than as not-local: falling through to SMTP on an unknown answer would send to the internet a message that may belong here. Local delivery is still content-scanned; a virus between two accounts on one host is no less a virus. This also surfaced a permissive test double: fakeAliasFanOutRepository claimed every address was local, which was harmless while nothing consulted it while routing and wrong the moment something did. It now hosts one address, as the real repository does, and the DSN fan-out test names it explicitly. Verified: the local-delivery test fails without the change (the recipient triggers an MX lookup) and passes with it; 22 Go packages green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
161 lines
5.3 KiB
Go
161 lines
5.3 KiB
Go
package entity
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"strings"
|
|
|
|
"lambdamail/protocols/internal/domain/valueobject"
|
|
)
|
|
|
|
// Transport security modes, in the precedence order of RFC 8461 section 2:
|
|
// DANE wins when TLSA records exist, MTA-STS applies otherwise, and a
|
|
// destination with neither gets opportunistic TLS (RFC 7435).
|
|
const (
|
|
TLSModeDane = "dane"
|
|
TLSModeMtaSts = "mta-sts"
|
|
TLSModeOpportunistic = "opportunistic"
|
|
// TLSModeRelay records that the message left through a smarthost, which
|
|
// owns transport security from that point on (PLAN.md section 10.4).
|
|
TLSModeRelay = "relay"
|
|
// TLSModeLocal records a message that never crossed a network: the
|
|
// recipient is a mailbox on this same server, so there was no transport
|
|
// to secure. Distinct from opportunistic, which means a network hop was
|
|
// made without a policy to validate against.
|
|
TLSModeLocal = "local"
|
|
)
|
|
|
|
// TLSPolicy is the effective transport security policy for one outbound
|
|
// destination (PLAN.md sections 5 and 6.2).
|
|
type TLSPolicy struct {
|
|
daneAvailable bool
|
|
mtaSTSAvailable bool
|
|
|
|
// enforce is true when a validation failure must defer the message
|
|
// instead of falling back to an unvalidated connection. It is false for
|
|
// MTA-STS "testing", whose whole purpose is to report without breaking.
|
|
enforce bool
|
|
|
|
// mxPatterns are the "mx:" entries of the MTA-STS policy. A host that
|
|
// matches none of them is not covered by the policy.
|
|
mxPatterns []string
|
|
|
|
// tlsaRecords are the DANE-EE associations published for the target host.
|
|
tlsaRecords []valueobject.TLSARecord
|
|
}
|
|
|
|
// NewTLSPolicy builds an opportunistic-or-simple policy. It is kept for
|
|
// callers that only need the precedence decision.
|
|
func NewTLSPolicy(daneAvailable, mtaSTSAvailable bool) TLSPolicy {
|
|
return TLSPolicy{daneAvailable: daneAvailable, mtaSTSAvailable: mtaSTSAvailable}
|
|
}
|
|
|
|
// NewDanePolicy builds a policy backed by TLSA records. DANE is always
|
|
// enforcing: the records are DNSSEC-signed, so a mismatch is an attack or a
|
|
// misconfiguration, never an acceptable downgrade (RFC 7672 section 2.2).
|
|
func NewDanePolicy(records []valueobject.TLSARecord) TLSPolicy {
|
|
return TLSPolicy{daneAvailable: true, enforce: true, tlsaRecords: records}
|
|
}
|
|
|
|
// NewMtaStsPolicy builds a policy from a fetched MTA-STS policy file.
|
|
func NewMtaStsPolicy(mxPatterns []string, enforce bool) TLSPolicy {
|
|
return TLSPolicy{mtaSTSAvailable: true, enforce: enforce, mxPatterns: mxPatterns}
|
|
}
|
|
|
|
// Effective returns "dane", "mta-sts" or "opportunistic" following the
|
|
// precedence rule in PLAN.md section 5 / RFC 8461 section 2.
|
|
func (p TLSPolicy) Effective() string {
|
|
switch {
|
|
case p.daneAvailable:
|
|
return TLSModeDane
|
|
case p.mtaSTSAvailable:
|
|
return TLSModeMtaSts
|
|
default:
|
|
return TLSModeOpportunistic
|
|
}
|
|
}
|
|
|
|
// RequiresValidation reports whether the connection's certificate has to be
|
|
// checked before any mail is handed over.
|
|
func (p TLSPolicy) RequiresValidation() bool {
|
|
return p.enforce && (p.daneAvailable || p.mtaSTSAvailable)
|
|
}
|
|
|
|
// CoversHost reports whether a candidate MX is inside the policy's scope. An
|
|
// MTA-STS policy that does not list the host means the host must not be used
|
|
// under that policy (RFC 8461 section 4.1).
|
|
func (p TLSPolicy) CoversHost(host string) bool {
|
|
if !p.mtaSTSAvailable {
|
|
return true
|
|
}
|
|
for _, pattern := range p.mxPatterns {
|
|
if matchMxPattern(pattern, host) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// matchMxPattern implements the single-label wildcard of RFC 8461 section 4.1:
|
|
// "*.example.com" matches "mx1.example.com" but not "a.mx1.example.com".
|
|
func matchMxPattern(pattern, host string) bool {
|
|
pattern = strings.ToLower(strings.TrimSuffix(pattern, "."))
|
|
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
|
|
|
if !strings.HasPrefix(pattern, "*.") {
|
|
return pattern == host
|
|
}
|
|
|
|
suffix := pattern[1:] // ".example.com"
|
|
if !strings.HasSuffix(host, suffix) {
|
|
return false
|
|
}
|
|
label := strings.TrimSuffix(host, suffix)
|
|
return label != "" && !strings.Contains(label, ".")
|
|
}
|
|
|
|
// MatchesCertificate checks a presented certificate chain against the DANE
|
|
// associations. Only usage 3 (DANE-EE) is supported by explicit decision
|
|
// (PLAN.md section 5.1), so the end-entity certificate is what gets matched
|
|
// and the PKIX chain is irrelevant.
|
|
func (p TLSPolicy) MatchesCertificate(chain []*x509.Certificate) bool {
|
|
if len(p.tlsaRecords) == 0 || len(chain) == 0 {
|
|
return false
|
|
}
|
|
|
|
leaf := chain[0]
|
|
for _, record := range p.tlsaRecords {
|
|
if digestFor(leaf, record) == strings.ToLower(record.Data()) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// digestFor renders the certificate the way a TLSA record's selector and
|
|
// matching type describe it (RFC 6698 section 2.1.2).
|
|
func digestFor(cert *x509.Certificate, record valueobject.TLSARecord) string {
|
|
var material []byte
|
|
switch record.Selector() {
|
|
case 0: // full certificate
|
|
material = cert.Raw
|
|
case 1: // SubjectPublicKeyInfo
|
|
material = cert.RawSubjectPublicKeyInfo
|
|
default:
|
|
return ""
|
|
}
|
|
|
|
switch record.MatchingType() {
|
|
case 0: // exact match on the material itself
|
|
return strings.ToLower(hex.EncodeToString(material))
|
|
case 1: // SHA-256
|
|
sum := sha256.Sum256(material)
|
|
return hex.EncodeToString(sum[:])
|
|
default:
|
|
// SHA-512 (type 2) is deliberately unsupported: the reconciler only
|
|
// ever publishes "3 1 1" records (PLAN.md section 7.1).
|
|
return ""
|
|
}
|
|
}
|