2023-01-30 16:27:06 +03:00
|
|
|
package tlsrpt
|
|
|
|
|
|
|
|
import (
|
|
|
|
"compress/gzip"
|
|
|
|
"encoding/json"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"io"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/mjl-/mox/message"
|
|
|
|
"github.com/mjl-/mox/moxio"
|
|
|
|
)
|
|
|
|
|
|
|
|
var ErrNoReport = errors.New("no tlsrpt report found")
|
|
|
|
|
|
|
|
// ../rfc/8460:628
|
|
|
|
|
|
|
|
// Report is a TLSRPT report, transmitted in JSON format.
|
|
|
|
type Report struct {
|
|
|
|
OrganizationName string `json:"organization-name"`
|
|
|
|
DateRange TLSRPTDateRange `json:"date-range"`
|
|
|
|
ContactInfo string `json:"contact-info"` // Email address.
|
|
|
|
ReportID string `json:"report-id"`
|
|
|
|
Policies []Result `json:"policies"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// note: with TLSRPT prefix to prevent clash in sherpadoc types.
|
|
|
|
type TLSRPTDateRange struct {
|
|
|
|
Start time.Time `json:"start-datetime"`
|
|
|
|
End time.Time `json:"end-datetime"`
|
|
|
|
}
|
|
|
|
|
2023-02-05 12:55:34 +03:00
|
|
|
// UnmarshalJSON is defined on the date range, not the individual time.Time fields
|
|
|
|
// because it is easier to keep the unmodified time.Time fields stored in the
|
|
|
|
// database.
|
|
|
|
func (dr *TLSRPTDateRange) UnmarshalJSON(buf []byte) error {
|
|
|
|
var v struct {
|
|
|
|
Start xtime `json:"start-datetime"`
|
|
|
|
End xtime `json:"end-datetime"`
|
|
|
|
}
|
|
|
|
if err := json.Unmarshal(buf, &v); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
dr.Start = time.Time(v.Start)
|
|
|
|
dr.End = time.Time(v.End)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// xtime and its UnmarshalJSON exists to work around a specific invalid date-time encoding seen in the wild.
|
|
|
|
type xtime time.Time
|
|
|
|
|
|
|
|
func (x *xtime) UnmarshalJSON(buf []byte) error {
|
|
|
|
var t time.Time
|
|
|
|
err := t.UnmarshalJSON(buf)
|
|
|
|
if err == nil {
|
|
|
|
*x = xtime(t)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Microsoft is sending reports with invalid start-datetime/end-datetime (missing
|
|
|
|
// timezone, ../rfc/8460:682 ../rfc/3339:415). We compensate.
|
|
|
|
var s string
|
|
|
|
if err := json.Unmarshal(buf, &s); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
t, err = time.Parse("2006-01-02T15:04:05", s)
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
*x = xtime(t)
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
2023-01-30 16:27:06 +03:00
|
|
|
type Result struct {
|
|
|
|
Policy ResultPolicy `json:"policy"`
|
|
|
|
Summary Summary `json:"summary"`
|
|
|
|
FailureDetails []FailureDetails `json:"failure-details"`
|
|
|
|
}
|
|
|
|
|
|
|
|
type ResultPolicy struct {
|
|
|
|
Type string `json:"policy-type"`
|
|
|
|
String []string `json:"policy-string"`
|
|
|
|
Domain string `json:"policy-domain"`
|
|
|
|
MXHost []string `json:"mx-host"` // Example in RFC has errata, it originally was a single string. ../rfc/8460-eid6241 ../rfc/8460:1779
|
|
|
|
}
|
|
|
|
|
|
|
|
type Summary struct {
|
|
|
|
TotalSuccessfulSessionCount int64 `json:"total-successful-session-count"`
|
|
|
|
TotalFailureSessionCount int64 `json:"total-failure-session-count"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// ResultType represents a TLS error.
|
|
|
|
type ResultType string
|
|
|
|
|
|
|
|
// ../rfc/8460:1377
|
|
|
|
// https://www.iana.org/assignments/starttls-validation-result-types/starttls-validation-result-types.xhtml
|
|
|
|
|
|
|
|
const (
|
|
|
|
ResultSTARTTLSNotSupported ResultType = "starttls-not-supported"
|
|
|
|
ResultCertificateHostMismatch ResultType = "certificate-host-mismatch"
|
|
|
|
ResultCertificateExpired ResultType = "certificate-expired"
|
|
|
|
ResultTLSAInvalid ResultType = "tlsa-invalid"
|
|
|
|
ResultDNSSECInvalid ResultType = "dnssec-invalid"
|
|
|
|
ResultDANERequired ResultType = "dane-required"
|
|
|
|
ResultCertificateNotTrusted ResultType = "certificate-not-trusted"
|
|
|
|
ResultSTSPolicyInvalid ResultType = "sts-policy-invalid"
|
|
|
|
ResultSTSWebPKIInvalid ResultType = "sts-webpki-invalid"
|
|
|
|
ResultValidationFailure ResultType = "validation-failure" // Other error.
|
|
|
|
ResultSTSPolicyFetch ResultType = "sts-policy-fetch-error"
|
|
|
|
)
|
|
|
|
|
|
|
|
type FailureDetails struct {
|
|
|
|
ResultType ResultType `json:"result-type"`
|
|
|
|
SendingMTAIP string `json:"sending-mta-ip"`
|
|
|
|
ReceivingMXHostname string `json:"receiving-mx-hostname"`
|
|
|
|
ReceivingMXHelo string `json:"receiving-mx-helo"`
|
|
|
|
ReceivingIP string `json:"receiving-ip"`
|
|
|
|
FailedSessionCount int64 `json:"failed-session-count"`
|
|
|
|
AdditionalInformation string `json:"additional-information"`
|
|
|
|
FailureReasonCode string `json:"failure-reason-code"`
|
|
|
|
}
|
|
|
|
|
|
|
|
// Parse parses a Report.
|
|
|
|
// The maximum size is 20MB.
|
|
|
|
func Parse(r io.Reader) (*Report, error) {
|
|
|
|
r = &moxio.LimitReader{R: r, Limit: 20 * 1024 * 1024}
|
|
|
|
var report Report
|
|
|
|
if err := json.NewDecoder(r).Decode(&report); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
// note: there may be leftover data, we ignore it.
|
|
|
|
return &report, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// ParseMessage parses a Report from a mail message.
|
|
|
|
// The maximum size of the message is 15MB, the maximum size of the
|
|
|
|
// decompressed report is 20MB.
|
|
|
|
func ParseMessage(r io.ReaderAt) (*Report, error) {
|
|
|
|
// ../rfc/8460:905
|
|
|
|
p, err := message.Parse(&moxio.LimitAtReader{R: r, Limit: 15 * 1024 * 1024})
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("parsing mail message: %s", err)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Using multipart appears optional, and similar to DMARC someone may decide to
|
|
|
|
// send it like that, so accept a report if it's the entire message.
|
|
|
|
const allow = true
|
|
|
|
return parseMessageReport(p, allow)
|
|
|
|
}
|
|
|
|
|
|
|
|
func parseMessageReport(p message.Part, allow bool) (*Report, error) {
|
|
|
|
if p.MediaType != "MULTIPART" {
|
|
|
|
if !allow {
|
|
|
|
return nil, ErrNoReport
|
|
|
|
}
|
|
|
|
return parseReport(p)
|
|
|
|
}
|
|
|
|
|
|
|
|
for {
|
|
|
|
sp, err := p.ParseNextPart()
|
|
|
|
if err == io.EOF {
|
|
|
|
return nil, ErrNoReport
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
if p.MediaSubType == "REPORT" && p.ContentTypeParams["report-type"] != "tlsrpt" {
|
|
|
|
return nil, fmt.Errorf("unknown report-type parameter %q", p.ContentTypeParams["report-type"])
|
|
|
|
}
|
|
|
|
report, err := parseMessageReport(*sp, p.MediaSubType == "REPORT")
|
|
|
|
if err == ErrNoReport {
|
|
|
|
continue
|
|
|
|
} else if err != nil || report != nil {
|
|
|
|
return report, err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func parseReport(p message.Part) (*Report, error) {
|
|
|
|
mt := strings.ToLower(p.MediaType + "/" + p.MediaSubType)
|
|
|
|
switch mt {
|
|
|
|
case "application/tlsrpt+json":
|
|
|
|
return Parse(p.Reader())
|
|
|
|
case "application/tlsrpt+gzip":
|
|
|
|
gzr, err := gzip.NewReader(p.Reader())
|
|
|
|
if err != nil {
|
|
|
|
return nil, fmt.Errorf("decoding gzip TLSRPT report: %s", err)
|
|
|
|
}
|
|
|
|
return Parse(gzr)
|
|
|
|
}
|
|
|
|
return nil, ErrNoReport
|
|
|
|
}
|