19 "golang.org/x/exp/slices"
21 "github.com/mjl-/adns"
23 "github.com/mjl-/mox/dns"
24 "github.com/mjl-/mox/message"
25 "github.com/mjl-/mox/mlog"
26 "github.com/mjl-/mox/moxio"
29var ErrNoReport = errors.New("no tlsrpt report found")
33// Report is a TLSRPT report, transmitted in JSON format.
35 OrganizationName string `json:"organization-name"`
36 DateRange TLSRPTDateRange `json:"date-range"`
37 ContactInfo string `json:"contact-info"` // Email address.
38 ReportID string `json:"report-id"`
39 Policies []Result `json:"policies"`
42// Merge combines the counts and failure details of results into the report.
43// Policies are merged if identical and added otherwise. Same for failure details
45func (r *Report) Merge(results ...Result) {
47 for _, nr := range results {
48 for i, p := range r.Policies {
49 if !p.Policy.equal(nr.Policy) {
53 r.Policies[i].Add(nr.Summary.TotalSuccessfulSessionCount, nr.Summary.TotalFailureSessionCount, nr.FailureDetails...)
57 r.Policies = append(r.Policies, nr)
61// Add increases the success/failure counts of a result, and adds any failure
63func (r *Result) Add(success, failure int64, fds ...FailureDetails) {
64 r.Summary.TotalSuccessfulSessionCount += success
65 r.Summary.TotalFailureSessionCount += failure
67 // In smtpclient we can compensate with a negative success, after failed read after
68 // successful handshake. Sanity check that we never get negative counts.
69 if r.Summary.TotalSuccessfulSessionCount < 0 {
70 r.Summary.TotalSuccessfulSessionCount = 0
72 if r.Summary.TotalFailureSessionCount < 0 {
73 r.Summary.TotalFailureSessionCount = 0
77 for _, nfd := range fds {
78 for i, fd := range r.FailureDetails {
79 if !fd.equalKey(nfd) {
83 fd.FailedSessionCount += nfd.FailedSessionCount
84 r.FailureDetails[i] = fd
87 r.FailureDetails = append(r.FailureDetails, nfd)
91// Add is a convenience function for merging making a Result and merging it into
93func (r *Report) Add(policy ResultPolicy, success, failure int64, fds ...FailureDetails) {
94 r.Merge(Result{policy, Summary{success, failure}, fds})
97// TLSAPolicy returns a policy for DANE.
98func TLSAPolicy(records []adns.TLSA, tlsaBaseDomain dns.Domain) ResultPolicy {
101 l := make([]string, len(records))
102 for i, r := range records {
105 sort.Strings(l) // For consistent equals.
109 Domain: tlsaBaseDomain.ASCII,
114func MakeResult(policyType PolicyType, domain dns.Domain, fds ...FailureDetails) Result {
116 fds = []FailureDetails{}
119 Policy: ResultPolicy{Type: policyType, Domain: domain.ASCII, String: []string{}, MXHost: []string{}},
124// note: with TLSRPT prefix to prevent clash in sherpadoc types.
125type TLSRPTDateRange struct {
126 Start time.Time `json:"start-datetime"`
127 End time.Time `json:"end-datetime"`
130// UnmarshalJSON is defined on the date range, not the individual time.Time fields
131// because it is easier to keep the unmodified time.Time fields stored in the
133func (dr *TLSRPTDateRange) UnmarshalJSON(buf []byte) error {
135 Start xtime `json:"start-datetime"`
136 End xtime `json:"end-datetime"`
138 if err := json.Unmarshal(buf, &v); err != nil {
141 dr.Start = time.Time(v.Start)
142 dr.End = time.Time(v.End)
146// xtime and its UnmarshalJSON exists to work around a specific invalid date-time encoding seen in the wild.
149func (x *xtime) UnmarshalJSON(buf []byte) error {
151 err := t.UnmarshalJSON(buf)
157 // Microsoft is sending reports with invalid start-datetime/end-datetime (missing
160 if err := json.Unmarshal(buf, &s); err != nil {
163 t, err = time.Parse("2006-01-02T15:04:05", s)
172 Policy ResultPolicy `json:"policy"`
173 Summary Summary `json:"summary"`
174 FailureDetails []FailureDetails `json:"failure-details"`
177// todo spec:
../rfc/8460:437 says policy is a string, with rules for turning dane records into a single string. perhaps a remnant of an earlier version (for mtasts a single string would have made more sense). i doubt the intention is to always have a single element in policy-string (though the field name is singular).
179type ResultPolicy struct {
180 Type PolicyType `json:"policy-type"`
181 String []string `json:"policy-string"`
182 Domain string `json:"policy-domain"`
186// PolicyType indicates the policy success/failure results are for.
187type PolicyType string
190 // For DANE, against a mail host (not recipient domain).
191 TLSA PolicyType = "tlsa"
193 // For MTA-STS, against a recipient domain (not a mail host).
194 STS PolicyType = "sts"
196 // Recipient domain did not have MTA-STS policy, or mail host (TSLA base domain)
197 // did not have DANE TLSA records.
198 NoPolicyFound PolicyType = "no-policy-found"
199 // todo spec:
../rfc/8460:440 ../rfc/8460:697 suggest to replace with values like "no-sts-found" and "no-tlsa-found" to make it explicit which policy isn't found. also easier to implement, because you don't have to handle leaving out an sts no-policy-found result for a mail host when a tlsa policy is present.
202func (rp ResultPolicy) equal(orp ResultPolicy) bool {
203 return rp.Type == orp.Type && slices.Equal(rp.String, orp.String) && rp.Domain == orp.Domain && slices.Equal(rp.MXHost, orp.MXHost)
207 TotalSuccessfulSessionCount int64 `json:"total-successful-session-count"`
208 TotalFailureSessionCount int64 `json:"total-failure-session-count"`
211// ResultType represents a TLS error.
212type ResultType string
215// https://www.iana.org/assignments/starttls-validation-result-types/starttls-validation-result-types.xhtml
218 ResultSTARTTLSNotSupported ResultType = "starttls-not-supported"
219 ResultCertificateHostMismatch ResultType = "certificate-host-mismatch"
220 ResultCertificateExpired ResultType = "certificate-expired"
221 ResultTLSAInvalid ResultType = "tlsa-invalid"
222 ResultDNSSECInvalid ResultType = "dnssec-invalid"
223 ResultDANERequired ResultType = "dane-required"
224 ResultCertificateNotTrusted ResultType = "certificate-not-trusted"
225 ResultSTSPolicyInvalid ResultType = "sts-policy-invalid"
226 ResultSTSWebPKIInvalid ResultType = "sts-webpki-invalid"
227 ResultValidationFailure ResultType = "validation-failure" // Other error.
228 ResultSTSPolicyFetch ResultType = "sts-policy-fetch-error"
231// todo spec:
../rfc/8460:719 more of these fields should be optional. some sts failure details, like failed policy fetches, won't have an ip or mx, the failure happens earlier in the delivery process.
233type FailureDetails struct {
234 ResultType ResultType `json:"result-type"`
235 SendingMTAIP string `json:"sending-mta-ip"`
236 ReceivingMXHostname string `json:"receiving-mx-hostname"`
237 ReceivingMXHelo string `json:"receiving-mx-helo,omitempty"`
238 ReceivingIP string `json:"receiving-ip"`
239 FailedSessionCount int64 `json:"failed-session-count"`
240 AdditionalInformation string `json:"additional-information"`
241 FailureReasonCode string `json:"failure-reason-code"`
244// equalKey returns whether FailureDetails have the same values, expect for
245// FailedSessionCount. Useful for aggregating FailureDetails.
246func (fd FailureDetails) equalKey(ofd FailureDetails) bool {
247 fd.FailedSessionCount = 0
248 ofd.FailedSessionCount = 0
252// Details is a convenience function to compose a FailureDetails.
253func Details(t ResultType, r string) FailureDetails {
254 return FailureDetails{ResultType: t, FailedSessionCount: 1, FailureReasonCode: r}
257var invalidReasons = map[x509.InvalidReason]string{
258 x509.NotAuthorizedToSign: "not-authorized-to-sign",
259 x509.Expired: "certificate-expired",
260 x509.CANotAuthorizedForThisName: "ca-not-authorized-for-this-name",
261 x509.TooManyIntermediates: "too-many-intermediates",
262 x509.IncompatibleUsage: "incompatible-key-usage",
263 x509.NameMismatch: "parent-subject-child-issuer-mismatch",
264 x509.NameConstraintsWithoutSANs: "name-constraint-without-sans",
265 x509.UnconstrainedName: "unconstrained-name",
266 x509.TooManyConstraints: "too-many-constraints",
267 x509.CANotAuthorizedForExtKeyUsage: "ca-not-authorized-for-ext-key-usage",
270// TLSFailureDetails turns errors encountered during TLS handshakes into a result
271// type and failure reason code for use with FailureDetails.
273// Errors from crypto/tls, including local and remote alerts, from crypto/x509,
274// and generic i/o and timeout errors are recognized.
275func TLSFailureDetails(err error) (ResultType, string) {
276 var invalidErr x509.CertificateInvalidError
277 var hostErr x509.HostnameError
278 var unknownAuthErr x509.UnknownAuthorityError
279 var rootsErr x509.SystemRootsError
280 var verifyErr *tls.CertificateVerificationError
281 var netErr *net.OpError
282 var recordHdrErr tls.RecordHeaderError
283 if errors.As(err, &invalidErr) {
284 if invalidErr.Reason == x509.Expired {
286 return ResultCertificateExpired, ""
288 s, ok := invalidReasons[invalidErr.Reason]
290 s = fmt.Sprintf("go-x509-invalid-reason-%d", invalidErr.Reason)
293 return ResultCertificateNotTrusted, s
294 } else if errors.As(err, &hostErr) {
296 return ResultCertificateHostMismatch, ""
297 } else if errors.As(err, &unknownAuthErr) {
299 return ResultCertificateNotTrusted, ""
300 } else if errors.As(err, &rootsErr) {
302 return ResultCertificateNotTrusted, "no-system-roots"
303 } else if errors.As(err, &verifyErr) {
306 return ResultValidationFailure, "unknown-go-certificate-verification-error"
307 } else if errors.As(err, &netErr) && netErr.Op == "remote error" {
308 // This is how TLS errors from the server (through an alert) are represented by
309 // crypto/tls. Err will usually be tls.alert error that is a type around uint8.
310 reasonCode := "tls-remote-error"
311 if netErr.Err != nil {
312 // todo: ideally, crypto/tls would let us check if this is an alert. it could be another uint8-typed error.
313 v := reflect.ValueOf(netErr.Err)
314 if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" {
315 reasonCode = "tls-remote-" + formatAlert(uint8(v.Uint()))
318 return ResultValidationFailure, reasonCode
319 } else if errors.As(err, &recordHdrErr) {
320 // Like for AlertError, not a lot of details, but better than nothing.
322 return ResultValidationFailure, "tls-record-header-error"
325 // Consider not adding failure details at all for transient errors? It probably
326 // isn't very common to have an accidental connection failure during STARTTL setup
327 // after having completed SMTP TCP setup and having exchanged commands. Seems best
329 // Could be any other kind of error, we try to report on i/o errors, but best not to claim any
332 var reasonCode string
333 if errors.Is(err, os.ErrDeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) {
334 reasonCode = "io-timeout-during-handshake"
335 } else if moxio.IsClosed(err) || errors.Is(err, io.ErrClosedPipe) {
336 reasonCode = "connection-closed-during-handshake"
338 // Attempt to get a local, outgoing TLS alert.
339 // We unwrap the error to the end (not multiple errors), and check for uint8 of a
340 // type named "alert".
342 uerr := errors.Unwrap(err)
348 v := reflect.ValueOf(err)
349 if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" {
350 reasonCode = "tls-local-" + formatAlert(uint8(v.Uint()))
353 return ResultValidationFailure, reasonCode
356// Parse parses a Report.
357// The maximum size is 20MB.
358func Parse(r io.Reader) (*Report, error) {
359 r = &moxio.LimitReader{R: r, Limit: 20 * 1024 * 1024}
361 if err := json.NewDecoder(r).Decode(&report); err != nil {
364 // note: there may be leftover data, we ignore it.
368// ParseMessage parses a Report from a mail message.
369// The maximum size of the message is 15MB, the maximum size of the
370// decompressed report is 20MB.
371func ParseMessage(log *mlog.Log, r io.ReaderAt) (*Report, error) {
373 p, err := message.Parse(log, true, &moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
375 return nil, fmt.Errorf("parsing mail message: %s", err)
378 // Using multipart appears optional, and similar to DMARC someone may decide to
379 // send it like that, so accept a report if it's the entire message.
381 return parseMessageReport(log, p, allow)
384func parseMessageReport(log *mlog.Log, p message.Part, allow bool) (*Report, error) {
385 if p.MediaType != "MULTIPART" {
387 return nil, ErrNoReport
389 return parseReport(p)
393 sp, err := p.ParseNextPart(log)
395 return nil, ErrNoReport
400 if p.MediaSubType == "REPORT" && p.ContentTypeParams["report-type"] != "tlsrpt" {
401 return nil, fmt.Errorf("unknown report-type parameter %q", p.ContentTypeParams["report-type"])
403 report, err := parseMessageReport(log, *sp, p.MediaSubType == "REPORT")
404 if err == ErrNoReport {
406 } else if err != nil || report != nil {
412func parseReport(p message.Part) (*Report, error) {
413 mt := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
415 case "application/tlsrpt+json":
416 return Parse(p.Reader())
417 case "application/tlsrpt+gzip":
418 gzr, err := gzip.NewReader(p.Reader())
420 return nil, fmt.Errorf("decoding gzip TLSRPT report: %s", err)
424 return nil, ErrNoReport