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.
35 OrganizationName string
36 DateRange TLSRPTDateRange
42// ReportJSON is a TLS report with field names as used in the specification. These field names are inconvenient to use in JavaScript, so after parsing a ReportJSON is turned into a Report.
43type ReportJSON struct {
44 OrganizationName string `json:"organization-name"`
45 DateRange TLSRPTDateRangeJSON `json:"date-range"`
46 ContactInfo string `json:"contact-info"` // Email address.
47 ReportID string `json:"report-id"`
48 Policies []ResultJSON `json:"policies"`
51func convertSlice[T interface{ Convert() S }, S any](l []T) []S {
55 r := make([]S, len(l))
62func (v Report) Convert() ReportJSON {
63 return ReportJSON{v.OrganizationName, v.DateRange.Convert(), v.ContactInfo, v.ReportID, convertSlice[Result, ResultJSON](v.Policies)}
66func (v ReportJSON) Convert() Report {
67 return Report{v.OrganizationName, v.DateRange.Convert(), v.ContactInfo, v.ReportID, convertSlice[ResultJSON, Result](v.Policies)}
70// Merge combines the counts and failure details of results into the report.
71// Policies are merged if identical and added otherwise. Same for failure details
73func (r *Report) Merge(results ...Result) {
75 for _, nr := range results {
76 for i, p := range r.Policies {
77 if !p.Policy.equal(nr.Policy) {
81 r.Policies[i].Add(nr.Summary.TotalSuccessfulSessionCount, nr.Summary.TotalFailureSessionCount, nr.FailureDetails...)
85 r.Policies = append(r.Policies, nr)
89// Add increases the success/failure counts of a result, and adds any failure
91func (r *Result) Add(success, failure int64, fds ...FailureDetails) {
92 r.Summary.TotalSuccessfulSessionCount += success
93 r.Summary.TotalFailureSessionCount += failure
95 // In smtpclient we can compensate with a negative success, after failed read after
96 // successful handshake. Sanity check that we never get negative counts.
97 if r.Summary.TotalSuccessfulSessionCount < 0 {
98 r.Summary.TotalSuccessfulSessionCount = 0
100 if r.Summary.TotalFailureSessionCount < 0 {
101 r.Summary.TotalFailureSessionCount = 0
105 for _, nfd := range fds {
106 for i, fd := range r.FailureDetails {
107 if !fd.equalKey(nfd) {
111 fd.FailedSessionCount += nfd.FailedSessionCount
112 r.FailureDetails[i] = fd
115 r.FailureDetails = append(r.FailureDetails, nfd)
119// Add is a convenience function for merging making a Result and merging it into
121func (r *Report) Add(policy ResultPolicy, success, failure int64, fds ...FailureDetails) {
122 r.Merge(Result{policy, Summary{success, failure}, fds})
125// TLSAPolicy returns a policy for DANE.
126func TLSAPolicy(records []adns.TLSA, tlsaBaseDomain dns.Domain) ResultPolicy {
129 l := make([]string, len(records))
130 for i, r := range records {
133 sort.Strings(l) // For consistent equals.
137 Domain: tlsaBaseDomain.ASCII,
142func MakeResult(policyType PolicyType, domain dns.Domain, fds ...FailureDetails) Result {
144 fds = []FailureDetails{}
147 Policy: ResultPolicy{Type: policyType, Domain: domain.ASCII, String: []string{}, MXHost: []string{}},
152// note: with TLSRPT prefix to prevent clash in sherpadoc types.
153type TLSRPTDateRange struct {
158func (v TLSRPTDateRange) Convert() TLSRPTDateRangeJSON {
159 return TLSRPTDateRangeJSON(v)
162type TLSRPTDateRangeJSON struct {
163 Start time.Time `json:"start-datetime"`
164 End time.Time `json:"end-datetime"`
167func (v TLSRPTDateRangeJSON) Convert() TLSRPTDateRange {
168 return TLSRPTDateRange(v)
171// UnmarshalJSON is defined on the date range, not the individual time.Time fields
172// because it is easier to keep the unmodified time.Time fields stored in the
174func (dr *TLSRPTDateRangeJSON) UnmarshalJSON(buf []byte) error {
176 Start xtime `json:"start-datetime"`
177 End xtime `json:"end-datetime"`
179 if err := json.Unmarshal(buf, &v); err != nil {
182 dr.Start = time.Time(v.Start)
183 dr.End = time.Time(v.End)
187// xtime and its UnmarshalJSON exists to work around a specific invalid date-time encoding seen in the wild.
190func (x *xtime) UnmarshalJSON(buf []byte) error {
192 err := t.UnmarshalJSON(buf)
198 // Microsoft is sending reports with invalid start-datetime/end-datetime (missing
201 if err := json.Unmarshal(buf, &s); err != nil {
204 t, err = time.Parse("2006-01-02T15:04:05", s)
215 FailureDetails []FailureDetails
218func (r Result) Convert() ResultJSON {
219 return ResultJSON{ResultPolicyJSON(r.Policy), SummaryJSON(r.Summary), convertSlice[FailureDetails, FailureDetailsJSON](r.FailureDetails)}
222type ResultJSON struct {
223 Policy ResultPolicyJSON `json:"policy"`
224 Summary SummaryJSON `json:"summary"`
225 FailureDetails []FailureDetailsJSON `json:"failure-details"`
228func (r ResultJSON) Convert() Result {
229 return Result{ResultPolicy(r.Policy), Summary(r.Summary), convertSlice[FailureDetailsJSON, FailureDetails](r.FailureDetails)}
232// 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).
234type ResultPolicy struct {
241type ResultPolicyJSON struct {
242 Type PolicyType `json:"policy-type"`
243 String []string `json:"policy-string"`
244 Domain string `json:"policy-domain"`
248// PolicyType indicates the policy success/failure results are for.
249type PolicyType string
252 // For DANE, against a mail host (not recipient domain).
253 TLSA PolicyType = "tlsa"
255 // For MTA-STS, against a recipient domain (not a mail host).
256 STS PolicyType = "sts"
258 // Recipient domain did not have MTA-STS policy, or mail host (TSLA base domain)
259 // did not have DANE TLSA records.
260 NoPolicyFound PolicyType = "no-policy-found"
261 // 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.
264func (rp ResultPolicy) equal(orp ResultPolicy) bool {
265 return rp.Type == orp.Type && slices.Equal(rp.String, orp.String) && rp.Domain == orp.Domain && slices.Equal(rp.MXHost, orp.MXHost)
269 TotalSuccessfulSessionCount int64
270 TotalFailureSessionCount int64
273type SummaryJSON struct {
274 TotalSuccessfulSessionCount int64 `json:"total-successful-session-count"`
275 TotalFailureSessionCount int64 `json:"total-failure-session-count"`
278// ResultType represents a TLS error.
279type ResultType string
282// https://www.iana.org/assignments/starttls-validation-result-types/starttls-validation-result-types.xhtml
285 ResultSTARTTLSNotSupported ResultType = "starttls-not-supported"
286 ResultCertificateHostMismatch ResultType = "certificate-host-mismatch"
287 ResultCertificateExpired ResultType = "certificate-expired"
288 ResultTLSAInvalid ResultType = "tlsa-invalid"
289 ResultDNSSECInvalid ResultType = "dnssec-invalid"
290 ResultDANERequired ResultType = "dane-required"
291 ResultCertificateNotTrusted ResultType = "certificate-not-trusted"
292 ResultSTSPolicyInvalid ResultType = "sts-policy-invalid"
293 ResultSTSWebPKIInvalid ResultType = "sts-webpki-invalid"
294 ResultValidationFailure ResultType = "validation-failure" // Other error.
295 ResultSTSPolicyFetch ResultType = "sts-policy-fetch-error"
298// 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.
300type FailureDetails struct {
301 ResultType ResultType
303 ReceivingMXHostname string
304 ReceivingMXHelo string
306 FailedSessionCount int64
307 AdditionalInformation string
308 FailureReasonCode string
311func (v FailureDetails) Convert() FailureDetailsJSON { return FailureDetailsJSON(v) }
313type FailureDetailsJSON struct {
314 ResultType ResultType `json:"result-type"`
315 SendingMTAIP string `json:"sending-mta-ip"`
316 ReceivingMXHostname string `json:"receiving-mx-hostname"`
317 ReceivingMXHelo string `json:"receiving-mx-helo,omitempty"`
318 ReceivingIP string `json:"receiving-ip"`
319 FailedSessionCount int64 `json:"failed-session-count"`
320 AdditionalInformation string `json:"additional-information"`
321 FailureReasonCode string `json:"failure-reason-code"`
324func (v FailureDetailsJSON) Convert() FailureDetails { return FailureDetails(v) }
326// equalKey returns whether FailureDetails have the same values, expect for
327// FailedSessionCount. Useful for aggregating FailureDetails.
328func (fd FailureDetails) equalKey(ofd FailureDetails) bool {
329 fd.FailedSessionCount = 0
330 ofd.FailedSessionCount = 0
334// Details is a convenience function to compose a FailureDetails.
335func Details(t ResultType, r string) FailureDetails {
336 return FailureDetails{ResultType: t, FailedSessionCount: 1, FailureReasonCode: r}
339var invalidReasons = map[x509.InvalidReason]string{
340 x509.NotAuthorizedToSign: "not-authorized-to-sign",
341 x509.Expired: "certificate-expired",
342 x509.CANotAuthorizedForThisName: "ca-not-authorized-for-this-name",
343 x509.TooManyIntermediates: "too-many-intermediates",
344 x509.IncompatibleUsage: "incompatible-key-usage",
345 x509.NameMismatch: "parent-subject-child-issuer-mismatch",
346 x509.NameConstraintsWithoutSANs: "name-constraint-without-sans",
347 x509.UnconstrainedName: "unconstrained-name",
348 x509.TooManyConstraints: "too-many-constraints",
349 x509.CANotAuthorizedForExtKeyUsage: "ca-not-authorized-for-ext-key-usage",
352// TLSFailureDetails turns errors encountered during TLS handshakes into a result
353// type and failure reason code for use with FailureDetails.
355// Errors from crypto/tls, including local and remote alerts, from crypto/x509,
356// and generic i/o and timeout errors are recognized.
357func TLSFailureDetails(err error) (ResultType, string) {
358 var invalidErr x509.CertificateInvalidError
359 var hostErr x509.HostnameError
360 var unknownAuthErr x509.UnknownAuthorityError
361 var rootsErr x509.SystemRootsError
362 var verifyErr *tls.CertificateVerificationError
363 var netErr *net.OpError
364 var recordHdrErr tls.RecordHeaderError
365 if errors.As(err, &invalidErr) {
366 if invalidErr.Reason == x509.Expired {
368 return ResultCertificateExpired, ""
370 s, ok := invalidReasons[invalidErr.Reason]
372 s = fmt.Sprintf("go-x509-invalid-reason-%d", invalidErr.Reason)
375 return ResultCertificateNotTrusted, s
376 } else if errors.As(err, &hostErr) {
378 return ResultCertificateHostMismatch, ""
379 } else if errors.As(err, &unknownAuthErr) {
381 return ResultCertificateNotTrusted, ""
382 } else if errors.As(err, &rootsErr) {
384 return ResultCertificateNotTrusted, "no-system-roots"
385 } else if errors.As(err, &verifyErr) {
388 return ResultValidationFailure, "unknown-go-certificate-verification-error"
389 } else if errors.As(err, &netErr) && netErr.Op == "remote error" {
390 // This is how TLS errors from the server (through an alert) are represented by
391 // crypto/tls. Err will usually be tls.alert error that is a type around uint8.
392 reasonCode := "tls-remote-error"
393 if netErr.Err != nil {
394 // todo: ideally, crypto/tls would let us check if this is an alert. it could be another uint8-typed error.
395 v := reflect.ValueOf(netErr.Err)
396 if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" {
397 reasonCode = "tls-remote-" + formatAlert(uint8(v.Uint()))
400 return ResultValidationFailure, reasonCode
401 } else if errors.As(err, &recordHdrErr) {
402 // Like for AlertError, not a lot of details, but better than nothing.
404 return ResultValidationFailure, "tls-record-header-error"
407 // Consider not adding failure details at all for transient errors? It probably
408 // isn't very common to have an accidental connection failure during STARTTL setup
409 // after having completed SMTP TCP setup and having exchanged commands. Seems best
411 // Could be any other kind of error, we try to report on i/o errors, but best not to claim any
414 var reasonCode string
415 if errors.Is(err, os.ErrDeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) {
416 reasonCode = "io-timeout-during-handshake"
417 } else if moxio.IsClosed(err) || errors.Is(err, io.ErrClosedPipe) {
418 reasonCode = "connection-closed-during-handshake"
420 // Attempt to get a local, outgoing TLS alert.
421 // We unwrap the error to the end (not multiple errors), and check for uint8 of a
422 // type named "alert".
424 uerr := errors.Unwrap(err)
430 v := reflect.ValueOf(err)
431 if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" {
432 reasonCode = "tls-local-" + formatAlert(uint8(v.Uint()))
435 return ResultValidationFailure, reasonCode
438// Parse parses a Report.
439// The maximum size is 20MB.
440func Parse(r io.Reader) (*ReportJSON, error) {
441 r = &moxio.LimitReader{R: r, Limit: 20 * 1024 * 1024}
442 var report ReportJSON
443 if err := json.NewDecoder(r).Decode(&report); err != nil {
446 // note: there may be leftover data, we ignore it.
450// ParseMessage parses a Report from a mail message.
451// The maximum size of the message is 15MB, the maximum size of the
452// decompressed report is 20MB.
453func ParseMessage(elog *slog.Logger, r io.ReaderAt) (*ReportJSON, error) {
454 log := mlog.New("tlsrpt", elog)
457 p, err := message.Parse(log.Logger, true, &moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
459 return nil, fmt.Errorf("parsing mail message: %s", err)
462 // Using multipart appears optional, and similar to DMARC someone may decide to
463 // send it like that, so accept a report if it's the entire message.
465 return parseMessageReport(log, p, allow)
468func parseMessageReport(log mlog.Log, p message.Part, allow bool) (*ReportJSON, error) {
469 if p.MediaType != "MULTIPART" {
471 return nil, ErrNoReport
473 return parseReport(p)
477 sp, err := p.ParseNextPart(log.Logger)
479 return nil, ErrNoReport
484 if p.MediaSubType == "REPORT" && p.ContentTypeParams["report-type"] != "tlsrpt" {
485 return nil, fmt.Errorf("unknown report-type parameter %q", p.ContentTypeParams["report-type"])
487 report, err := parseMessageReport(log, *sp, p.MediaSubType == "REPORT")
488 if err == ErrNoReport {
490 } else if err != nil || report != nil {
496func parseReport(p message.Part) (*ReportJSON, error) {
497 mt := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
499 case "application/tlsrpt+json":
500 return Parse(p.Reader())
501 case "application/tlsrpt+gzip":
502 gzr, err := gzip.NewReader(p.Reader())
504 return nil, fmt.Errorf("decoding gzip TLSRPT report: %s", err)
508 return nil, ErrNoReport