1package tlsrpt
2
3import (
4 "compress/gzip"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "strings"
10 "time"
11
12 "github.com/mjl-/mox/message"
13 "github.com/mjl-/mox/mlog"
14 "github.com/mjl-/mox/moxio"
15)
16
17var ErrNoReport = errors.New("no tlsrpt report found")
18
19// ../rfc/8460:628
20
21// Report is a TLSRPT report, transmitted in JSON format.
22type Report struct {
23 OrganizationName string `json:"organization-name"`
24 DateRange TLSRPTDateRange `json:"date-range"`
25 ContactInfo string `json:"contact-info"` // Email address.
26 ReportID string `json:"report-id"`
27 Policies []Result `json:"policies"`
28}
29
30// note: with TLSRPT prefix to prevent clash in sherpadoc types.
31type TLSRPTDateRange struct {
32 Start time.Time `json:"start-datetime"`
33 End time.Time `json:"end-datetime"`
34}
35
36// UnmarshalJSON is defined on the date range, not the individual time.Time fields
37// because it is easier to keep the unmodified time.Time fields stored in the
38// database.
39func (dr *TLSRPTDateRange) UnmarshalJSON(buf []byte) error {
40 var v struct {
41 Start xtime `json:"start-datetime"`
42 End xtime `json:"end-datetime"`
43 }
44 if err := json.Unmarshal(buf, &v); err != nil {
45 return err
46 }
47 dr.Start = time.Time(v.Start)
48 dr.End = time.Time(v.End)
49 return nil
50}
51
52// xtime and its UnmarshalJSON exists to work around a specific invalid date-time encoding seen in the wild.
53type xtime time.Time
54
55func (x *xtime) UnmarshalJSON(buf []byte) error {
56 var t time.Time
57 err := t.UnmarshalJSON(buf)
58 if err == nil {
59 *x = xtime(t)
60 return nil
61 }
62
63 // Microsoft is sending reports with invalid start-datetime/end-datetime (missing
64 // timezone, ../rfc/8460:682 ../rfc/3339:415). We compensate.
65 var s string
66 if err := json.Unmarshal(buf, &s); err != nil {
67 return err
68 }
69 t, err = time.Parse("2006-01-02T15:04:05", s)
70 if err != nil {
71 return err
72 }
73 *x = xtime(t)
74 return nil
75}
76
77type Result struct {
78 Policy ResultPolicy `json:"policy"`
79 Summary Summary `json:"summary"`
80 FailureDetails []FailureDetails `json:"failure-details"`
81}
82
83type ResultPolicy struct {
84 Type string `json:"policy-type"`
85 String []string `json:"policy-string"`
86 Domain string `json:"policy-domain"`
87 MXHost []string `json:"mx-host"` // Example in RFC has errata, it originally was a single string. ../rfc/8460-eid6241 ../rfc/8460:1779
88}
89
90type Summary struct {
91 TotalSuccessfulSessionCount int64 `json:"total-successful-session-count"`
92 TotalFailureSessionCount int64 `json:"total-failure-session-count"`
93}
94
95// ResultType represents a TLS error.
96type ResultType string
97
98// ../rfc/8460:1377
99// https://www.iana.org/assignments/starttls-validation-result-types/starttls-validation-result-types.xhtml
100
101const (
102 ResultSTARTTLSNotSupported ResultType = "starttls-not-supported"
103 ResultCertificateHostMismatch ResultType = "certificate-host-mismatch"
104 ResultCertificateExpired ResultType = "certificate-expired"
105 ResultTLSAInvalid ResultType = "tlsa-invalid"
106 ResultDNSSECInvalid ResultType = "dnssec-invalid"
107 ResultDANERequired ResultType = "dane-required"
108 ResultCertificateNotTrusted ResultType = "certificate-not-trusted"
109 ResultSTSPolicyInvalid ResultType = "sts-policy-invalid"
110 ResultSTSWebPKIInvalid ResultType = "sts-webpki-invalid"
111 ResultValidationFailure ResultType = "validation-failure" // Other error.
112 ResultSTSPolicyFetch ResultType = "sts-policy-fetch-error"
113)
114
115type FailureDetails struct {
116 ResultType ResultType `json:"result-type"`
117 SendingMTAIP string `json:"sending-mta-ip"`
118 ReceivingMXHostname string `json:"receiving-mx-hostname"`
119 ReceivingMXHelo string `json:"receiving-mx-helo"`
120 ReceivingIP string `json:"receiving-ip"`
121 FailedSessionCount int64 `json:"failed-session-count"`
122 AdditionalInformation string `json:"additional-information"`
123 FailureReasonCode string `json:"failure-reason-code"`
124}
125
126// Parse parses a Report.
127// The maximum size is 20MB.
128func Parse(r io.Reader) (*Report, error) {
129 r = &moxio.LimitReader{R: r, Limit: 20 * 1024 * 1024}
130 var report Report
131 if err := json.NewDecoder(r).Decode(&report); err != nil {
132 return nil, err
133 }
134 // note: there may be leftover data, we ignore it.
135 return &report, nil
136}
137
138// ParseMessage parses a Report from a mail message.
139// The maximum size of the message is 15MB, the maximum size of the
140// decompressed report is 20MB.
141func ParseMessage(log *mlog.Log, r io.ReaderAt) (*Report, error) {
142 // ../rfc/8460:905
143 p, err := message.Parse(log, true, &moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
144 if err != nil {
145 return nil, fmt.Errorf("parsing mail message: %s", err)
146 }
147
148 // Using multipart appears optional, and similar to DMARC someone may decide to
149 // send it like that, so accept a report if it's the entire message.
150 const allow = true
151 return parseMessageReport(log, p, allow)
152}
153
154func parseMessageReport(log *mlog.Log, p message.Part, allow bool) (*Report, error) {
155 if p.MediaType != "MULTIPART" {
156 if !allow {
157 return nil, ErrNoReport
158 }
159 return parseReport(p)
160 }
161
162 for {
163 sp, err := p.ParseNextPart(log)
164 if err == io.EOF {
165 return nil, ErrNoReport
166 }
167 if err != nil {
168 return nil, err
169 }
170 if p.MediaSubType == "REPORT" && p.ContentTypeParams["report-type"] != "tlsrpt" {
171 return nil, fmt.Errorf("unknown report-type parameter %q", p.ContentTypeParams["report-type"])
172 }
173 report, err := parseMessageReport(log, *sp, p.MediaSubType == "REPORT")
174 if err == ErrNoReport {
175 continue
176 } else if err != nil || report != nil {
177 return report, err
178 }
179 }
180}
181
182func parseReport(p message.Part) (*Report, error) {
183 mt := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
184 switch mt {
185 case "application/tlsrpt+json":
186 return Parse(p.Reader())
187 case "application/tlsrpt+gzip":
188 gzr, err := gzip.NewReader(p.Reader())
189 if err != nil {
190 return nil, fmt.Errorf("decoding gzip TLSRPT report: %s", err)
191 }
192 return Parse(gzr)
193 }
194 return nil, ErrNoReport
195}
196