19 "golang.org/x/exp/slices"
20 "golang.org/x/exp/slog"
22 "github.com/mjl-/adns"
24 "github.com/mjl-/mox/dns"
25 "github.com/mjl-/mox/message"
26 "github.com/mjl-/mox/mlog"
27 "github.com/mjl-/mox/moxio"
30var ErrNoReport = errors.New("no tlsrpt report found")
34// Report is a TLSRPT report.
36 OrganizationName string
37 DateRange TLSRPTDateRange
43// 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.
44type ReportJSON struct {
45 OrganizationName string `json:"organization-name"`
46 DateRange TLSRPTDateRangeJSON `json:"date-range"`
47 ContactInfo string `json:"contact-info"` // Email address.
48 ReportID string `json:"report-id"`
49 Policies []ResultJSON `json:"policies"`
52func convertSlice[T interface{ Convert() S }, S any](l []T) []S {
56 r := make([]S, len(l))
63func (v Report) Convert() ReportJSON {
64 return ReportJSON{v.OrganizationName, v.DateRange.Convert(), v.ContactInfo, v.ReportID, convertSlice[Result, ResultJSON](v.Policies)}
67func (v ReportJSON) Convert() Report {
68 return Report{v.OrganizationName, v.DateRange.Convert(), v.ContactInfo, v.ReportID, convertSlice[ResultJSON, Result](v.Policies)}
71// Merge combines the counts and failure details of results into the report.
72// Policies are merged if identical and added otherwise. Same for failure details
74func (r *Report) Merge(results ...Result) {
76 for _, nr := range results {
77 for i, p := range r.Policies {
78 if !p.Policy.equal(nr.Policy) {
82 r.Policies[i].Add(nr.Summary.TotalSuccessfulSessionCount, nr.Summary.TotalFailureSessionCount, nr.FailureDetails...)
86 r.Policies = append(r.Policies, nr)
90// Add increases the success/failure counts of a result, and adds any failure
92func (r *Result) Add(success, failure int64, fds ...FailureDetails) {
93 r.Summary.TotalSuccessfulSessionCount += success
94 r.Summary.TotalFailureSessionCount += failure
96 // In smtpclient we can compensate with a negative success, after failed read after
97 // successful handshake. Sanity check that we never get negative counts.
98 if r.Summary.TotalSuccessfulSessionCount < 0 {
99 r.Summary.TotalSuccessfulSessionCount = 0
101 if r.Summary.TotalFailureSessionCount < 0 {
102 r.Summary.TotalFailureSessionCount = 0
106 for _, nfd := range fds {
107 for i, fd := range r.FailureDetails {
108 if !fd.equalKey(nfd) {
112 fd.FailedSessionCount += nfd.FailedSessionCount
113 r.FailureDetails[i] = fd
116 r.FailureDetails = append(r.FailureDetails, nfd)
120// Add is a convenience function for merging making a Result and merging it into
122func (r *Report) Add(policy ResultPolicy, success, failure int64, fds ...FailureDetails) {
123 r.Merge(Result{policy, Summary{success, failure}, fds})
126// TLSAPolicy returns a policy for DANE.
127func TLSAPolicy(records []adns.TLSA, tlsaBaseDomain dns.Domain) ResultPolicy {
130 l := make([]string, len(records))
131 for i, r := range records {
134 sort.Strings(l) // For consistent equals.
138 Domain: tlsaBaseDomain.ASCII,
143func MakeResult(policyType PolicyType, domain dns.Domain, fds ...FailureDetails) Result {
145 fds = []FailureDetails{}
148 Policy: ResultPolicy{Type: policyType, Domain: domain.ASCII, String: []string{}, MXHost: []string{}},
153// note: with TLSRPT prefix to prevent clash in sherpadoc types.
154type TLSRPTDateRange struct {
159func (v TLSRPTDateRange) Convert() TLSRPTDateRangeJSON {
160 return TLSRPTDateRangeJSON(v)
163type TLSRPTDateRangeJSON struct {
164 Start time.Time `json:"start-datetime"`
165 End time.Time `json:"end-datetime"`
168func (v TLSRPTDateRangeJSON) Convert() TLSRPTDateRange {
169 return TLSRPTDateRange(v)
172// UnmarshalJSON is defined on the date range, not the individual time.Time fields
173// because it is easier to keep the unmodified time.Time fields stored in the
175func (dr *TLSRPTDateRangeJSON) UnmarshalJSON(buf []byte) error {
177 Start xtime `json:"start-datetime"`
178 End xtime `json:"end-datetime"`
180 if err := json.Unmarshal(buf, &v); err != nil {
183 dr.Start = time.Time(v.Start)
184 dr.End = time.Time(v.End)
188// xtime and its UnmarshalJSON exists to work around a specific invalid date-time encoding seen in the wild.
191func (x *xtime) UnmarshalJSON(buf []byte) error {
193 err := t.UnmarshalJSON(buf)
199 // Microsoft is sending reports with invalid start-datetime/end-datetime (missing
202 if err := json.Unmarshal(buf, &s); err != nil {
205 t, err = time.Parse("2006-01-02T15:04:05", s)
216 FailureDetails []FailureDetails
219func (r Result) Convert() ResultJSON {
220 return ResultJSON{ResultPolicyJSON(r.Policy), SummaryJSON(r.Summary), convertSlice[FailureDetails, FailureDetailsJSON](r.FailureDetails)}
223type ResultJSON struct {
224 Policy ResultPolicyJSON `json:"policy"`
225 Summary SummaryJSON `json:"summary"`
226 FailureDetails []FailureDetailsJSON `json:"failure-details"`
229func (r ResultJSON) Convert() Result {
230 return Result{ResultPolicy(r.Policy), Summary(r.Summary), convertSlice[FailureDetailsJSON, FailureDetails](r.FailureDetails)}
233// 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).
235type ResultPolicy struct {
242type ResultPolicyJSON struct {
243 Type PolicyType `json:"policy-type"`
244 String []string `json:"policy-string"`
245 Domain string `json:"policy-domain"`
249// PolicyType indicates the policy success/failure results are for.
250type PolicyType string
253 // For DANE, against a mail host (not recipient domain).
254 TLSA PolicyType = "tlsa"
256 // For MTA-STS, against a recipient domain (not a mail host).
257 STS PolicyType = "sts"
259 // Recipient domain did not have MTA-STS policy, or mail host (TSLA base domain)
260 // did not have DANE TLSA records.
261 NoPolicyFound PolicyType = "no-policy-found"
262 // 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.
265func (rp ResultPolicy) equal(orp ResultPolicy) bool {
266 return rp.Type == orp.Type && slices.Equal(rp.String, orp.String) && rp.Domain == orp.Domain && slices.Equal(rp.MXHost, orp.MXHost)
270 TotalSuccessfulSessionCount int64
271 TotalFailureSessionCount int64
274type SummaryJSON struct {
275 TotalSuccessfulSessionCount int64 `json:"total-successful-session-count"`
276 TotalFailureSessionCount int64 `json:"total-failure-session-count"`
279// ResultType represents a TLS error.
280type ResultType string
283// https://www.iana.org/assignments/starttls-validation-result-types/starttls-validation-result-types.xhtml
286 ResultSTARTTLSNotSupported ResultType = "starttls-not-supported"
287 ResultCertificateHostMismatch ResultType = "certificate-host-mismatch"
288 ResultCertificateExpired ResultType = "certificate-expired"
289 ResultTLSAInvalid ResultType = "tlsa-invalid"
290 ResultDNSSECInvalid ResultType = "dnssec-invalid"
291 ResultDANERequired ResultType = "dane-required"
292 ResultCertificateNotTrusted ResultType = "certificate-not-trusted"
293 ResultSTSPolicyInvalid ResultType = "sts-policy-invalid"
294 ResultSTSWebPKIInvalid ResultType = "sts-webpki-invalid"
295 ResultValidationFailure ResultType = "validation-failure" // Other error.
296 ResultSTSPolicyFetch ResultType = "sts-policy-fetch-error"
299// 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.
301type FailureDetails struct {
302 ResultType ResultType
304 ReceivingMXHostname string
305 ReceivingMXHelo string
307 FailedSessionCount int64
308 AdditionalInformation string
309 FailureReasonCode string
312func (v FailureDetails) Convert() FailureDetailsJSON { return FailureDetailsJSON(v) }
314type FailureDetailsJSON struct {
315 ResultType ResultType `json:"result-type"`
316 SendingMTAIP string `json:"sending-mta-ip"`
317 ReceivingMXHostname string `json:"receiving-mx-hostname"`
318 ReceivingMXHelo string `json:"receiving-mx-helo,omitempty"`
319 ReceivingIP string `json:"receiving-ip"`
320 FailedSessionCount int64 `json:"failed-session-count"`
321 AdditionalInformation string `json:"additional-information"`
322 FailureReasonCode string `json:"failure-reason-code"`
325func (v FailureDetailsJSON) Convert() FailureDetails { return FailureDetails(v) }
327// equalKey returns whether FailureDetails have the same values, expect for
328// FailedSessionCount. Useful for aggregating FailureDetails.
329func (fd FailureDetails) equalKey(ofd FailureDetails) bool {
330 fd.FailedSessionCount = 0
331 ofd.FailedSessionCount = 0
335// Details is a convenience function to compose a FailureDetails.
336func Details(t ResultType, r string) FailureDetails {
337 return FailureDetails{ResultType: t, FailedSessionCount: 1, FailureReasonCode: r}
340var invalidReasons = map[x509.InvalidReason]string{
341 x509.NotAuthorizedToSign: "not-authorized-to-sign",
342 x509.Expired: "certificate-expired",
343 x509.CANotAuthorizedForThisName: "ca-not-authorized-for-this-name",
344 x509.TooManyIntermediates: "too-many-intermediates",
345 x509.IncompatibleUsage: "incompatible-key-usage",
346 x509.NameMismatch: "parent-subject-child-issuer-mismatch",
347 x509.NameConstraintsWithoutSANs: "name-constraint-without-sans",
348 x509.UnconstrainedName: "unconstrained-name",
349 x509.TooManyConstraints: "too-many-constraints",
350 x509.CANotAuthorizedForExtKeyUsage: "ca-not-authorized-for-ext-key-usage",
353// TLSFailureDetails turns errors encountered during TLS handshakes into a result
354// type and failure reason code for use with FailureDetails.
356// Errors from crypto/tls, including local and remote alerts, from crypto/x509,
357// and generic i/o and timeout errors are recognized.
358func TLSFailureDetails(err error) (ResultType, string) {
359 var invalidErr x509.CertificateInvalidError
360 var hostErr x509.HostnameError
361 var unknownAuthErr x509.UnknownAuthorityError
362 var rootsErr x509.SystemRootsError
363 var verifyErr *tls.CertificateVerificationError
364 var netErr *net.OpError
365 var recordHdrErr tls.RecordHeaderError
366 if errors.As(err, &invalidErr) {
367 if invalidErr.Reason == x509.Expired {
369 return ResultCertificateExpired, ""
371 s, ok := invalidReasons[invalidErr.Reason]
373 s = fmt.Sprintf("go-x509-invalid-reason-%d", invalidErr.Reason)
376 return ResultCertificateNotTrusted, s
377 } else if errors.As(err, &hostErr) {
379 return ResultCertificateHostMismatch, ""
380 } else if errors.As(err, &unknownAuthErr) {
382 return ResultCertificateNotTrusted, ""
383 } else if errors.As(err, &rootsErr) {
385 return ResultCertificateNotTrusted, "no-system-roots"
386 } else if errors.As(err, &verifyErr) {
389 return ResultValidationFailure, "unknown-go-certificate-verification-error"
390 } else if errors.As(err, &netErr) && netErr.Op == "remote error" {
391 // This is how TLS errors from the server (through an alert) are represented by
392 // crypto/tls. Err will usually be tls.alert error that is a type around uint8.
393 reasonCode := "tls-remote-error"
394 if netErr.Err != nil {
395 // todo: ideally, crypto/tls would let us check if this is an alert. it could be another uint8-typed error.
396 v := reflect.ValueOf(netErr.Err)
397 if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" {
398 reasonCode = "tls-remote-" + formatAlert(uint8(v.Uint()))
401 return ResultValidationFailure, reasonCode
402 } else if errors.As(err, &recordHdrErr) {
403 // Like for AlertError, not a lot of details, but better than nothing.
405 return ResultValidationFailure, "tls-record-header-error"
408 // Consider not adding failure details at all for transient errors? It probably
409 // isn't very common to have an accidental connection failure during STARTTL setup
410 // after having completed SMTP TCP setup and having exchanged commands. Seems best
412 // Could be any other kind of error, we try to report on i/o errors, but best not to claim any
415 var reasonCode string
416 if errors.Is(err, os.ErrDeadlineExceeded) || errors.Is(err, context.DeadlineExceeded) {
417 reasonCode = "io-timeout-during-handshake"
418 } else if moxio.IsClosed(err) || errors.Is(err, io.ErrClosedPipe) {
419 reasonCode = "connection-closed-during-handshake"
421 // Attempt to get a local, outgoing TLS alert.
422 // We unwrap the error to the end (not multiple errors), and check for uint8 of a
423 // type named "alert".
425 uerr := errors.Unwrap(err)
431 v := reflect.ValueOf(err)
432 if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" {
433 reasonCode = "tls-local-" + formatAlert(uint8(v.Uint()))
436 return ResultValidationFailure, reasonCode
439// Parse parses a Report.
440// The maximum size is 20MB.
441func Parse(r io.Reader) (*ReportJSON, error) {
442 r = &moxio.LimitReader{R: r, Limit: 20 * 1024 * 1024}
443 var report ReportJSON
444 if err := json.NewDecoder(r).Decode(&report); err != nil {
447 // note: there may be leftover data, we ignore it.
451// ParseMessage parses a Report from a mail message.
452// The maximum size of the message is 15MB, the maximum size of the
453// decompressed report is 20MB.
454func ParseMessage(elog *slog.Logger, r io.ReaderAt) (*ReportJSON, error) {
455 log := mlog.New("tlsrpt", elog)
458 p, err := message.Parse(log.Logger, true, &moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
460 return nil, fmt.Errorf("parsing mail message: %s", err)
463 // Using multipart appears optional, and similar to DMARC someone may decide to
464 // send it like that, so accept a report if it's the entire message.
466 return parseMessageReport(log, p, allow)
469func parseMessageReport(log mlog.Log, p message.Part, allow bool) (*ReportJSON, error) {
470 if p.MediaType != "MULTIPART" {
472 return nil, ErrNoReport
474 return parseReport(p)
478 sp, err := p.ParseNextPart(log.Logger)
480 return nil, ErrNoReport
485 if p.MediaSubType == "REPORT" && p.ContentTypeParams["report-type"] != "tlsrpt" {
486 return nil, fmt.Errorf("unknown report-type parameter %q", p.ContentTypeParams["report-type"])
488 report, err := parseMessageReport(log, *sp, p.MediaSubType == "REPORT")
489 if err == ErrNoReport {
491 } else if err != nil || report != nil {
497func parseReport(p message.Part) (*ReportJSON, error) {
498 mt := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
500 case "application/tlsrpt+json":
501 return Parse(p.Reader())
502 case "application/tlsrpt+gzip":
503 gzr, err := gzip.NewReader(p.Reader())
505 return nil, fmt.Errorf("decoding gzip TLSRPT report: %s", err)
509 return nil, ErrNoReport