1package tlsrpt
2
3import (
4 "compress/gzip"
5 "context"
6 "crypto/tls"
7 "crypto/x509"
8 "encoding/json"
9 "errors"
10 "fmt"
11 "io"
12 "net"
13 "os"
14 "reflect"
15 "sort"
16 "strings"
17 "time"
18
19 "golang.org/x/exp/slices"
20 "golang.org/x/exp/slog"
21
22 "github.com/mjl-/adns"
23
24 "github.com/mjl-/mox/dns"
25 "github.com/mjl-/mox/message"
26 "github.com/mjl-/mox/mlog"
27 "github.com/mjl-/mox/moxio"
28)
29
30var ErrNoReport = errors.New("no tlsrpt report found")
31
32// ../rfc/8460:628
33
34// Report is a TLSRPT report.
35type Report struct {
36 OrganizationName string
37 DateRange TLSRPTDateRange
38 ContactInfo string
39 ReportID string
40 Policies []Result
41}
42
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"`
50}
51
52func convertSlice[T interface{ Convert() S }, S any](l []T) []S {
53 if l == nil {
54 return nil
55 }
56 r := make([]S, len(l))
57 for i, e := range l {
58 r[i] = e.Convert()
59 }
60 return r
61}
62
63func (v Report) Convert() ReportJSON {
64 return ReportJSON{v.OrganizationName, v.DateRange.Convert(), v.ContactInfo, v.ReportID, convertSlice[Result, ResultJSON](v.Policies)}
65}
66
67func (v ReportJSON) Convert() Report {
68 return Report{v.OrganizationName, v.DateRange.Convert(), v.ContactInfo, v.ReportID, convertSlice[ResultJSON, Result](v.Policies)}
69}
70
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
73// within a result.
74func (r *Report) Merge(results ...Result) {
75Merge:
76 for _, nr := range results {
77 for i, p := range r.Policies {
78 if !p.Policy.equal(nr.Policy) {
79 continue
80 }
81
82 r.Policies[i].Add(nr.Summary.TotalSuccessfulSessionCount, nr.Summary.TotalFailureSessionCount, nr.FailureDetails...)
83 continue Merge
84 }
85
86 r.Policies = append(r.Policies, nr)
87 }
88}
89
90// Add increases the success/failure counts of a result, and adds any failure
91// details.
92func (r *Result) Add(success, failure int64, fds ...FailureDetails) {
93 r.Summary.TotalSuccessfulSessionCount += success
94 r.Summary.TotalFailureSessionCount += failure
95
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
100 }
101 if r.Summary.TotalFailureSessionCount < 0 {
102 r.Summary.TotalFailureSessionCount = 0
103 }
104
105Merge:
106 for _, nfd := range fds {
107 for i, fd := range r.FailureDetails {
108 if !fd.equalKey(nfd) {
109 continue
110 }
111
112 fd.FailedSessionCount += nfd.FailedSessionCount
113 r.FailureDetails[i] = fd
114 continue Merge
115 }
116 r.FailureDetails = append(r.FailureDetails, nfd)
117 }
118}
119
120// Add is a convenience function for merging making a Result and merging it into
121// the report.
122func (r *Report) Add(policy ResultPolicy, success, failure int64, fds ...FailureDetails) {
123 r.Merge(Result{policy, Summary{success, failure}, fds})
124}
125
126// TLSAPolicy returns a policy for DANE.
127func TLSAPolicy(records []adns.TLSA, tlsaBaseDomain dns.Domain) ResultPolicy {
128 // The policy domain is the TLSA base domain. ../rfc/8460:251
129
130 l := make([]string, len(records))
131 for i, r := range records {
132 l[i] = r.Record()
133 }
134 sort.Strings(l) // For consistent equals.
135 return ResultPolicy{
136 Type: TLSA,
137 String: l,
138 Domain: tlsaBaseDomain.ASCII,
139 MXHost: []string{},
140 }
141}
142
143func MakeResult(policyType PolicyType, domain dns.Domain, fds ...FailureDetails) Result {
144 if fds == nil {
145 fds = []FailureDetails{}
146 }
147 return Result{
148 Policy: ResultPolicy{Type: policyType, Domain: domain.ASCII, String: []string{}, MXHost: []string{}},
149 FailureDetails: fds,
150 }
151}
152
153// note: with TLSRPT prefix to prevent clash in sherpadoc types.
154type TLSRPTDateRange struct {
155 Start time.Time
156 End time.Time
157}
158
159func (v TLSRPTDateRange) Convert() TLSRPTDateRangeJSON {
160 return TLSRPTDateRangeJSON(v)
161}
162
163type TLSRPTDateRangeJSON struct {
164 Start time.Time `json:"start-datetime"`
165 End time.Time `json:"end-datetime"`
166}
167
168func (v TLSRPTDateRangeJSON) Convert() TLSRPTDateRange {
169 return TLSRPTDateRange(v)
170}
171
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
174// database.
175func (dr *TLSRPTDateRangeJSON) UnmarshalJSON(buf []byte) error {
176 var v struct {
177 Start xtime `json:"start-datetime"`
178 End xtime `json:"end-datetime"`
179 }
180 if err := json.Unmarshal(buf, &v); err != nil {
181 return err
182 }
183 dr.Start = time.Time(v.Start)
184 dr.End = time.Time(v.End)
185 return nil
186}
187
188// xtime and its UnmarshalJSON exists to work around a specific invalid date-time encoding seen in the wild.
189type xtime time.Time
190
191func (x *xtime) UnmarshalJSON(buf []byte) error {
192 var t time.Time
193 err := t.UnmarshalJSON(buf)
194 if err == nil {
195 *x = xtime(t)
196 return nil
197 }
198
199 // Microsoft is sending reports with invalid start-datetime/end-datetime (missing
200 // timezone, ../rfc/8460:682 ../rfc/3339:415). We compensate.
201 var s string
202 if err := json.Unmarshal(buf, &s); err != nil {
203 return err
204 }
205 t, err = time.Parse("2006-01-02T15:04:05", s)
206 if err != nil {
207 return err
208 }
209 *x = xtime(t)
210 return nil
211}
212
213type Result struct {
214 Policy ResultPolicy
215 Summary Summary
216 FailureDetails []FailureDetails
217}
218
219func (r Result) Convert() ResultJSON {
220 return ResultJSON{ResultPolicyJSON(r.Policy), SummaryJSON(r.Summary), convertSlice[FailureDetails, FailureDetailsJSON](r.FailureDetails)}
221}
222
223type ResultJSON struct {
224 Policy ResultPolicyJSON `json:"policy"`
225 Summary SummaryJSON `json:"summary"`
226 FailureDetails []FailureDetailsJSON `json:"failure-details"`
227}
228
229func (r ResultJSON) Convert() Result {
230 return Result{ResultPolicy(r.Policy), Summary(r.Summary), convertSlice[FailureDetailsJSON, FailureDetails](r.FailureDetails)}
231}
232
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).
234
235type ResultPolicy struct {
236 Type PolicyType
237 String []string
238 Domain string
239 MXHost []string
240}
241
242type ResultPolicyJSON struct {
243 Type PolicyType `json:"policy-type"`
244 String []string `json:"policy-string"`
245 Domain string `json:"policy-domain"`
246 MXHost []string `json:"mx-host"` // Example in RFC has errata, it originally was a single string. ../rfc/8460-eid6241 ../rfc/8460:1779
247}
248
249// PolicyType indicates the policy success/failure results are for.
250type PolicyType string
251
252const (
253 // For DANE, against a mail host (not recipient domain).
254 TLSA PolicyType = "tlsa"
255
256 // For MTA-STS, against a recipient domain (not a mail host).
257 STS PolicyType = "sts"
258
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.
263)
264
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)
267}
268
269type Summary struct {
270 TotalSuccessfulSessionCount int64
271 TotalFailureSessionCount int64
272}
273
274type SummaryJSON struct {
275 TotalSuccessfulSessionCount int64 `json:"total-successful-session-count"`
276 TotalFailureSessionCount int64 `json:"total-failure-session-count"`
277}
278
279// ResultType represents a TLS error.
280type ResultType string
281
282// ../rfc/8460:1377
283// https://www.iana.org/assignments/starttls-validation-result-types/starttls-validation-result-types.xhtml
284
285const (
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"
297)
298
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.
300
301type FailureDetails struct {
302 ResultType ResultType
303 SendingMTAIP string
304 ReceivingMXHostname string
305 ReceivingMXHelo string
306 ReceivingIP string
307 FailedSessionCount int64
308 AdditionalInformation string
309 FailureReasonCode string
310}
311
312func (v FailureDetails) Convert() FailureDetailsJSON { return FailureDetailsJSON(v) }
313
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"`
323}
324
325func (v FailureDetailsJSON) Convert() FailureDetails { return FailureDetails(v) }
326
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
332 return fd == ofd
333}
334
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}
338}
339
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",
351}
352
353// TLSFailureDetails turns errors encountered during TLS handshakes into a result
354// type and failure reason code for use with FailureDetails.
355//
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 {
368 // Result: ../rfc/8460:546
369 return ResultCertificateExpired, ""
370 }
371 s, ok := invalidReasons[invalidErr.Reason]
372 if !ok {
373 s = fmt.Sprintf("go-x509-invalid-reason-%d", invalidErr.Reason)
374 }
375 // Result: ../rfc/8460:549
376 return ResultCertificateNotTrusted, s
377 } else if errors.As(err, &hostErr) {
378 // Result: ../rfc/8460:541
379 return ResultCertificateHostMismatch, ""
380 } else if errors.As(err, &unknownAuthErr) {
381 // Result: ../rfc/8460:549
382 return ResultCertificateNotTrusted, ""
383 } else if errors.As(err, &rootsErr) {
384 // Result: ../rfc/8460:549
385 return ResultCertificateNotTrusted, "no-system-roots"
386 } else if errors.As(err, &verifyErr) {
387 // We don't know a more specific error. ../rfc/8460:610
388 // Result: ../rfc/8460:567
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()))
399 }
400 }
401 return ResultValidationFailure, reasonCode
402 } else if errors.As(err, &recordHdrErr) {
403 // Like for AlertError, not a lot of details, but better than nothing.
404 // Result: ../rfc/8460:567
405 return ResultValidationFailure, "tls-record-header-error"
406 }
407
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
411 // to report on them. ../rfc/8460:625
412 // Could be any other kind of error, we try to report on i/o errors, but best not to claim any
413 // other reason we don't know about. ../rfc/8460:610
414 // Result: ../rfc/8460:567
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"
420 } else {
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".
424 for {
425 uerr := errors.Unwrap(err)
426 if uerr == nil {
427 break
428 }
429 err = uerr
430 }
431 v := reflect.ValueOf(err)
432 if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" {
433 reasonCode = "tls-local-" + formatAlert(uint8(v.Uint()))
434 }
435 }
436 return ResultValidationFailure, reasonCode
437}
438
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 {
445 return nil, err
446 }
447 // note: there may be leftover data, we ignore it.
448 return &report, nil
449}
450
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)
456
457 // ../rfc/8460:905
458 p, err := message.Parse(log.Logger, true, &moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
459 if err != nil {
460 return nil, fmt.Errorf("parsing mail message: %s", err)
461 }
462
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.
465 const allow = true
466 return parseMessageReport(log, p, allow)
467}
468
469func parseMessageReport(log mlog.Log, p message.Part, allow bool) (*ReportJSON, error) {
470 if p.MediaType != "MULTIPART" {
471 if !allow {
472 return nil, ErrNoReport
473 }
474 return parseReport(p)
475 }
476
477 for {
478 sp, err := p.ParseNextPart(log.Logger)
479 if err == io.EOF {
480 return nil, ErrNoReport
481 }
482 if err != nil {
483 return nil, err
484 }
485 if p.MediaSubType == "REPORT" && p.ContentTypeParams["report-type"] != "tlsrpt" {
486 return nil, fmt.Errorf("unknown report-type parameter %q", p.ContentTypeParams["report-type"])
487 }
488 report, err := parseMessageReport(log, *sp, p.MediaSubType == "REPORT")
489 if err == ErrNoReport {
490 continue
491 } else if err != nil || report != nil {
492 return report, err
493 }
494 }
495}
496
497func parseReport(p message.Part) (*ReportJSON, error) {
498 mt := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
499 switch mt {
500 case "application/tlsrpt+json":
501 return Parse(p.Reader())
502 case "application/tlsrpt+gzip":
503 gzr, err := gzip.NewReader(p.Reader())
504 if err != nil {
505 return nil, fmt.Errorf("decoding gzip TLSRPT report: %s", err)
506 }
507 return Parse(gzr)
508 }
509 return nil, ErrNoReport
510}
511