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
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, transmitted in JSON format.
34type Report struct {
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"`
40}
41
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
44// within a result.
45func (r *Report) Merge(results ...Result) {
46Merge:
47 for _, nr := range results {
48 for i, p := range r.Policies {
49 if !p.Policy.equal(nr.Policy) {
50 continue
51 }
52
53 r.Policies[i].Add(nr.Summary.TotalSuccessfulSessionCount, nr.Summary.TotalFailureSessionCount, nr.FailureDetails...)
54 continue Merge
55 }
56
57 r.Policies = append(r.Policies, nr)
58 }
59}
60
61// Add increases the success/failure counts of a result, and adds any failure
62// details.
63func (r *Result) Add(success, failure int64, fds ...FailureDetails) {
64 r.Summary.TotalSuccessfulSessionCount += success
65 r.Summary.TotalFailureSessionCount += failure
66
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
71 }
72 if r.Summary.TotalFailureSessionCount < 0 {
73 r.Summary.TotalFailureSessionCount = 0
74 }
75
76Merge:
77 for _, nfd := range fds {
78 for i, fd := range r.FailureDetails {
79 if !fd.equalKey(nfd) {
80 continue
81 }
82
83 fd.FailedSessionCount += nfd.FailedSessionCount
84 r.FailureDetails[i] = fd
85 continue Merge
86 }
87 r.FailureDetails = append(r.FailureDetails, nfd)
88 }
89}
90
91// Add is a convenience function for merging making a Result and merging it into
92// the report.
93func (r *Report) Add(policy ResultPolicy, success, failure int64, fds ...FailureDetails) {
94 r.Merge(Result{policy, Summary{success, failure}, fds})
95}
96
97// TLSAPolicy returns a policy for DANE.
98func TLSAPolicy(records []adns.TLSA, tlsaBaseDomain dns.Domain) ResultPolicy {
99 // The policy domain is the TLSA base domain. ../rfc/8460:251
100
101 l := make([]string, len(records))
102 for i, r := range records {
103 l[i] = r.Record()
104 }
105 sort.Strings(l) // For consistent equals.
106 return ResultPolicy{
107 Type: TLSA,
108 String: l,
109 Domain: tlsaBaseDomain.ASCII,
110 MXHost: []string{},
111 }
112}
113
114func MakeResult(policyType PolicyType, domain dns.Domain, fds ...FailureDetails) Result {
115 if fds == nil {
116 fds = []FailureDetails{}
117 }
118 return Result{
119 Policy: ResultPolicy{Type: policyType, Domain: domain.ASCII, String: []string{}, MXHost: []string{}},
120 FailureDetails: fds,
121 }
122}
123
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"`
128}
129
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
132// database.
133func (dr *TLSRPTDateRange) UnmarshalJSON(buf []byte) error {
134 var v struct {
135 Start xtime `json:"start-datetime"`
136 End xtime `json:"end-datetime"`
137 }
138 if err := json.Unmarshal(buf, &v); err != nil {
139 return err
140 }
141 dr.Start = time.Time(v.Start)
142 dr.End = time.Time(v.End)
143 return nil
144}
145
146// xtime and its UnmarshalJSON exists to work around a specific invalid date-time encoding seen in the wild.
147type xtime time.Time
148
149func (x *xtime) UnmarshalJSON(buf []byte) error {
150 var t time.Time
151 err := t.UnmarshalJSON(buf)
152 if err == nil {
153 *x = xtime(t)
154 return nil
155 }
156
157 // Microsoft is sending reports with invalid start-datetime/end-datetime (missing
158 // timezone, ../rfc/8460:682 ../rfc/3339:415). We compensate.
159 var s string
160 if err := json.Unmarshal(buf, &s); err != nil {
161 return err
162 }
163 t, err = time.Parse("2006-01-02T15:04:05", s)
164 if err != nil {
165 return err
166 }
167 *x = xtime(t)
168 return nil
169}
170
171type Result struct {
172 Policy ResultPolicy `json:"policy"`
173 Summary Summary `json:"summary"`
174 FailureDetails []FailureDetails `json:"failure-details"`
175}
176
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).
178
179type ResultPolicy struct {
180 Type PolicyType `json:"policy-type"`
181 String []string `json:"policy-string"`
182 Domain string `json:"policy-domain"`
183 MXHost []string `json:"mx-host"` // Example in RFC has errata, it originally was a single string. ../rfc/8460-eid6241 ../rfc/8460:1779
184}
185
186// PolicyType indicates the policy success/failure results are for.
187type PolicyType string
188
189const (
190 // For DANE, against a mail host (not recipient domain).
191 TLSA PolicyType = "tlsa"
192
193 // For MTA-STS, against a recipient domain (not a mail host).
194 STS PolicyType = "sts"
195
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.
200)
201
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)
204}
205
206type Summary struct {
207 TotalSuccessfulSessionCount int64 `json:"total-successful-session-count"`
208 TotalFailureSessionCount int64 `json:"total-failure-session-count"`
209}
210
211// ResultType represents a TLS error.
212type ResultType string
213
214// ../rfc/8460:1377
215// https://www.iana.org/assignments/starttls-validation-result-types/starttls-validation-result-types.xhtml
216
217const (
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"
229)
230
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.
232
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"`
242}
243
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
249 return fd == ofd
250}
251
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}
255}
256
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",
268}
269
270// TLSFailureDetails turns errors encountered during TLS handshakes into a result
271// type and failure reason code for use with FailureDetails.
272//
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 {
285 // Result: ../rfc/8460:546
286 return ResultCertificateExpired, ""
287 }
288 s, ok := invalidReasons[invalidErr.Reason]
289 if !ok {
290 s = fmt.Sprintf("go-x509-invalid-reason-%d", invalidErr.Reason)
291 }
292 // Result: ../rfc/8460:549
293 return ResultCertificateNotTrusted, s
294 } else if errors.As(err, &hostErr) {
295 // Result: ../rfc/8460:541
296 return ResultCertificateHostMismatch, ""
297 } else if errors.As(err, &unknownAuthErr) {
298 // Result: ../rfc/8460:549
299 return ResultCertificateNotTrusted, ""
300 } else if errors.As(err, &rootsErr) {
301 // Result: ../rfc/8460:549
302 return ResultCertificateNotTrusted, "no-system-roots"
303 } else if errors.As(err, &verifyErr) {
304 // We don't know a more specific error. ../rfc/8460:610
305 // Result: ../rfc/8460:567
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()))
316 }
317 }
318 return ResultValidationFailure, reasonCode
319 } else if errors.As(err, &recordHdrErr) {
320 // Like for AlertError, not a lot of details, but better than nothing.
321 // Result: ../rfc/8460:567
322 return ResultValidationFailure, "tls-record-header-error"
323 }
324
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
328 // to report on them. ../rfc/8460:625
329 // Could be any other kind of error, we try to report on i/o errors, but best not to claim any
330 // other reason we don't know about. ../rfc/8460:610
331 // Result: ../rfc/8460:567
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"
337 } else {
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".
341 for {
342 uerr := errors.Unwrap(err)
343 if uerr == nil {
344 break
345 }
346 err = uerr
347 }
348 v := reflect.ValueOf(err)
349 if v.Kind() == reflect.Uint8 && v.Type().Name() == "alert" {
350 reasonCode = "tls-local-" + formatAlert(uint8(v.Uint()))
351 }
352 }
353 return ResultValidationFailure, reasonCode
354}
355
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}
360 var report Report
361 if err := json.NewDecoder(r).Decode(&report); err != nil {
362 return nil, err
363 }
364 // note: there may be leftover data, we ignore it.
365 return &report, nil
366}
367
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) {
372 // ../rfc/8460:905
373 p, err := message.Parse(log, true, &moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
374 if err != nil {
375 return nil, fmt.Errorf("parsing mail message: %s", err)
376 }
377
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.
380 const allow = true
381 return parseMessageReport(log, p, allow)
382}
383
384func parseMessageReport(log *mlog.Log, p message.Part, allow bool) (*Report, error) {
385 if p.MediaType != "MULTIPART" {
386 if !allow {
387 return nil, ErrNoReport
388 }
389 return parseReport(p)
390 }
391
392 for {
393 sp, err := p.ParseNextPart(log)
394 if err == io.EOF {
395 return nil, ErrNoReport
396 }
397 if err != nil {
398 return nil, err
399 }
400 if p.MediaSubType == "REPORT" && p.ContentTypeParams["report-type"] != "tlsrpt" {
401 return nil, fmt.Errorf("unknown report-type parameter %q", p.ContentTypeParams["report-type"])
402 }
403 report, err := parseMessageReport(log, *sp, p.MediaSubType == "REPORT")
404 if err == ErrNoReport {
405 continue
406 } else if err != nil || report != nil {
407 return report, err
408 }
409 }
410}
411
412func parseReport(p message.Part) (*Report, error) {
413 mt := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
414 switch mt {
415 case "application/tlsrpt+json":
416 return Parse(p.Reader())
417 case "application/tlsrpt+gzip":
418 gzr, err := gzip.NewReader(p.Reader())
419 if err != nil {
420 return nil, fmt.Errorf("decoding gzip TLSRPT report: %s", err)
421 }
422 return Parse(gzr)
423 }
424 return nil, ErrNoReport
425}
426