1/*2Maddy Mail Server - Composable all-in-one email server.3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors45This program is free software: you can redistribute it and/or modify6it under the terms of the GNU General Public License as published by7the Free Software Foundation, either version 3 of the License, or8(at your option) any later version.910This program is distributed in the hope that it will be useful,11but WITHOUT ANY WARRANTY; without even the implied warranty of12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the13GNU General Public License for more details.1415You should have received a copy of the GNU General Public License16along with this program. If not, see <https://www.gnu.org/licenses/>.17*/1819package dmarc2021import (22 "context"23 "errors"24 "fmt"25 "net"26 "net/mail"27 "strings"2829 "github.com/emersion/go-message/textproto"30 "github.com/emersion/go-msgauth/authres"31 "github.com/emersion/go-msgauth/dmarc"32 "github.com/foxcpp/maddy/framework/address"33 "github.com/foxcpp/maddy/framework/dns"34 "golang.org/x/net/publicsuffix"35)3637// FetchRecord looks up the DMARC record relevant for the RFC5322.From domain.38// It returns the record and the domain it was found with (may not be39// equal to the RFC5322.From domain).40func FetchRecord(ctx context.Context, r Resolver, fromDomain string) (policyDomain string, rec *Record, err error) {41 policyDomain = fromDomain4243 // 1. Lookup using From Domain.44 txts, err := r.LookupTXT(ctx, dns.FQDN("_dmarc."+fromDomain))45 if err != nil {46 dnsErr, ok := err.(*net.DNSError)47 if !ok || !dnsErr.IsNotFound {48 return "", nil, err49 }50 }51 if len(txts) == 0 {52 // No records or 'no such host', try orgDomain.53 orgDomain, err := publicsuffix.EffectiveTLDPlusOne(fromDomain)54 if err != nil {55 return "", nil, err56 }5758 policyDomain = orgDomain5960 txts, err = r.LookupTXT(ctx, dns.FQDN("_dmarc."+orgDomain))61 if err != nil {62 dnsErr, ok := err.(*net.DNSError)63 if !ok || !dnsErr.IsNotFound {64 return "", nil, err65 }66 }67 // Still nothing? Bail out.68 if len(txts) == 0 {69 return "", nil, nil70 }71 }7273 // Exclude records that are not DMARC policies.74 records := txts[:0]75 for _, txt := range txts {76 if strings.HasPrefix(txt, "v=DMARC1") {77 records = append(records, txt)78 }79 }80 // Multiple records => no record.81 if len(records) > 1 || len(records) == 0 {82 return "", nil, nil83 }8485 rec, err = dmarc.Parse(records[0])8687 return policyDomain, rec, err88}8990type EvalResult struct {91 // The Authentication-Results field generated as a result of the DMARC92 // check.93 Authres authres.DMARCResult9495 // The Authentication-Results field for SPF that was considered during96 // alignment check. May be empty.97 SPFResult authres.SPFResult9899 // Whether HELO or MAIL FROM match the RFC5322.From domain.100 SPFAligned bool101102 // The Authentication-Results field for the DKIM signature that is aligned,103 // if no signatures are aligned - this field contains the result for the104 // first signature. May be empty.105 DKIMResult authres.DKIMResult106107 // Whether there is a DKIM signature with the d= field matching the108 // RFC5322.From domain.109 DKIMAligned bool110}111112// EvaluateAlignment checks whether identifiers authenticated by SPF and DKIM are in alignment113// with the RFC5322.Domain.114//115// It returns EvalResult which contains the Authres field with the actual check result and116// a bunch of other trace information that can be useful for troubleshooting117// (and also report generation).118func EvaluateAlignment(fromDomain string, record *Record, results []authres.Result) EvalResult {119 var (120 spfAligned = false121 spfResult = authres.SPFResult{}122 dkimAligned = false123 dkimResult = authres.DKIMResult{}124 dkimPresent = false125 dkimTempFail = false126 )127 for _, res := range results {128 if dkimRes, ok := res.(*authres.DKIMResult); ok {129 dkimPresent = true130131 // We want to return DKIM result for a signature provided by the orgDomain,132 // in case there is none - return any (possibly misaligned) for reference.133 if dkimResult.Value == "" {134 dkimResult = *dkimRes135 }136 if isAligned(fromDomain, dkimRes.Domain, record.DKIMAlignment) {137 dkimResult = *dkimRes138 switch dkimRes.Value {139 case authres.ResultPass:140 dkimAligned = true141 case authres.ResultTempError:142 dkimTempFail = true143 }144 }145 }146 if spfRes, ok := res.(*authres.SPFResult); ok {147 spfResult = *spfRes148 var aligned bool149 if spfRes.From == "" {150 aligned = isAligned(fromDomain, spfRes.Helo, record.SPFAlignment)151 } else {152 aligned = isAligned(fromDomain, spfRes.From, record.SPFAlignment)153 }154 if aligned && spfRes.Value == authres.ResultPass {155 spfAligned = true156 }157 }158 }159160 res := EvalResult{161 SPFResult: spfResult,162 SPFAligned: spfAligned,163 DKIMResult: dkimResult,164 DKIMAligned: dkimAligned,165 }166167 if !dkimPresent || spfResult.Value == "" {168 res.Authres = authres.DMARCResult{169 Value: authres.ResultNone,170 Reason: "Not enough information (required checks are disabled)",171 From: fromDomain,172 }173 return res174 }175176 if dkimTempFail && !dkimAligned && !spfAligned {177 // We can't be sure whether it is aligned or not. Bail out.178 res.Authres = authres.DMARCResult{179 Value: authres.ResultTempError,180 Reason: "DKIM authentication temp error",181 From: fromDomain,182 }183 return res184 }185 if !dkimAligned && spfResult.Value == authres.ResultTempError {186 // We can't be sure whether it is aligned or not. Bail out.187 res.Authres = authres.DMARCResult{188 Value: authres.ResultTempError,189 Reason: "SPF authentication temp error",190 From: fromDomain,191 }192 return res193 }194195 res.Authres.From = fromDomain196 if dkimAligned || spfAligned {197 res.Authres.Value = authres.ResultPass198 } else {199 res.Authres.Value = authres.ResultFail200 res.Authres.Reason = "No aligned identifiers"201 }202 return res203}204205func isAligned(fromDomain, authDomain string, mode AlignmentMode) bool {206 if mode == dmarc.AlignmentStrict {207 return strings.EqualFold(fromDomain, authDomain)208 }209210 tld, _ := publicsuffix.PublicSuffix(fromDomain)211 if strings.EqualFold(fromDomain, tld) {212 return strings.EqualFold(fromDomain, authDomain)213 }214 orgDomainFrom, err := publicsuffix.EffectiveTLDPlusOne(fromDomain)215 if err != nil {216 return false217 }218 authDomainFrom, err := publicsuffix.EffectiveTLDPlusOne(authDomain)219 if err != nil {220 return false221 }222223 return strings.EqualFold(orgDomainFrom, authDomainFrom)224}225226func ExtractFromDomain(hdr textproto.Header) (string, error) {227 // TODO(GH emersion/go-message#75): Add textproto.Header.Count method.228 var firstFrom string229 for fields := hdr.FieldsByKey("From"); fields.Next(); {230 if firstFrom == "" {231 firstFrom = fields.Value()232 } else {233 return "", errors.New("dmarc: multiple From header fields are not allowed")234 }235 }236 if firstFrom == "" {237 return "", errors.New("dmarc: missing From header field")238 }239240 hdrFromList, err := mail.ParseAddressList(firstFrom)241 if err != nil {242 return "", fmt.Errorf("dmarc: malformed From header field: %s", strings.TrimPrefix(err.Error(), "mail: "))243 }244 if len(hdrFromList) > 1 {245 return "", errors.New("dmarc: multiple addresses in From field are not allowed")246 }247 if len(hdrFromList) == 0 {248 return "", errors.New("dmarc: missing address in From field")249 }250 _, domain, err := address.Split(hdrFromList[0].Address)251 if err != nil {252 return "", fmt.Errorf("dmarc: malformed From header field: %w", err)253 }254255 return domain, nil256}