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" ) // 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 "" } }