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