SMTP TLS Reporting (TLS-RPT, RFC 8460): finding where a domain wants its reports, building the JSON report, and sending it.
{:ok, uris} = TLSRPT.discover(resolver, "example.com")
#=> {:ok, ["mailto:tlsrpt@example.com", "https://reports.example.com/tlsrpt"]}
json = TLSRPT.report(report)
gzip = :zlib.gzip(json)
name = TLSRPT.filename("mx.example.net", "example.com", from, to, id)
#=> "mx.example.net!example.com!1700000000!1700086400!id.json.gz"Each destination gets the gzipped report: a mailto: one as the mail
message/1 builds, queued by the caller, and an https: one with
post/3.
A report covers one policy domain and one period, usually a UTC day
(RFC 8460 §4.1). It has one entry per policy that was applied: the
domain's DANE TLSA records, its MTA-STS policy, or :no_policy_found,
with the number of successful and failed sessions and the failures
grouped by their result type and hosts.
Summary
Types
A group of failed sessions with the same result and hosts. Every
member but result_type and count may be nil, and is then left
out of the report.
The report mail, see message/1.
One applied policy and its sessions. string is the policy as lines:
the TLSA records in presentation format, or the lines of the MTA-STS
policy. mx_host lists the MX patterns of an MTA-STS policy, or the
MX host of a TLSA policy. Empty lists are left out of the report.
The kind of policy a report entry is about (RFC 8460 §4.4).
Why no report destinations were found.
A report. contact_info is an address or URI to reach the reporting
organization; begin and end bound the reported period.
Why a session failed (RFC 8460 §4.3). result_type_name/1 gives the
name used in reports.
Functions
Looks up the TLS-RPT record of domain, at _smtp._tls.<domain>, and
returns its report destinations. See parse_record/1.
The file name of a report (RFC 8460 §5.1):
submitter!policy-domain!begin!end[!unique-id].json.gz, with the
times as Unix timestamps. The unique ID should be letters and digits;
nil or "" leaves it out.
Builds the report mail for mailto: destinations (RFC 8460 §5.3): a
multipart/report with report-type="tlsrpt", a short text, and the
gzipped report as an application/tlsrpt+gzip attachment.
Parses the TXT records found at _smtp._tls.<domain> (RFC 8460 §3)
and returns the rua URIs a report can be sent to, in order.
The name of a policy type in reports (RFC 8460 §4.4).
Sends a gzipped report to an https: destination (RFC 8460 §5.2): a
POST with Content-Type: application/tlsrpt+gzip. Any 2xx status is
success; redirects are not followed.
Builds the JSON document of a report (RFC 8460 §4.4). Members come in
the RFC's order, so the output depends only on report. Times are
RFC 3339 in UTC, to the second; IP addresses are in text form.
nil members, empty policy-string and mx-host lists, and an empty
failure-details list are left out.
The name of a result type in reports (RFC 8460 §4.3).
Returns every result type, in RFC 8460 §4.3 order.
Types
@type failure() :: %{ :result_type => result_type(), :count => pos_integer(), optional(:sending_mta_ip) => :inet.ip_address() | nil, optional(:receiving_mx_hostname) => String.t() | nil, optional(:receiving_mx_helo) => String.t() | nil, optional(:receiving_ip) => :inet.ip_address() | nil, optional(:additional_information) => String.t() | nil, optional(:failure_reason_code) => String.t() | nil }
A group of failed sessions with the same result and hosts. Every
member but result_type and count may be nil, and is then left
out of the report.
@type message_opts() :: %{ :from => String.t(), :to => [String.t(), ...], :domain => String.t(), :submitter => String.t(), :report_id => String.t(), :filename => String.t(), :gzip => binary(), :hostname => String.t(), optional(:date) => DateTime.t(), optional(:message_id) => String.t(), optional(:boundary) => String.t() }
The report mail, see message/1.
@type policy() :: %{ :type => policy_type(), :domain => String.t(), :successful => non_neg_integer(), :failed => non_neg_integer(), optional(:string) => [String.t()], optional(:mx_host) => [String.t()], optional(:failures) => [failure()] }
One applied policy and its sessions. string is the policy as lines:
the TLSA records in presentation format, or the lines of the MTA-STS
policy. mx_host lists the MX patterns of an MTA-STS policy, or the
MX host of a TLSA policy. Empty lists are left out of the report.
@type policy_type() :: :tlsa | :sts | :no_policy_found
The kind of policy a report entry is about (RFC 8460 §4.4).
@type record_error() :: :no_record | :multiple_records | :invalid_record
Why no report destinations were found.
@type report() :: %{ :organization_name => String.t(), :report_id => String.t(), :begin => DateTime.t(), :end => DateTime.t(), :policies => [policy()], optional(:contact_info) => String.t() | nil }
A report. contact_info is an address or URI to reach the reporting
organization; begin and end bound the reported period.
@type result_type() ::
:starttls_not_supported
| :certificate_host_mismatch
| :certificate_expired
| :certificate_not_trusted
| :validation_failure
| :tlsa_invalid
| :dnssec_invalid
| :dane_required
| :sts_policy_fetch_error
| :sts_policy_invalid
| :sts_webpki_invalid
Why a session failed (RFC 8460 §4.3). result_type_name/1 gives the
name used in reports.
Functions
@spec discover(Sovite.DNS.resolver(), String.t()) :: {:ok, [String.t(), ...]} | {:error, record_error() | {:dns, term()}}
Looks up the TLS-RPT record of domain, at _smtp._tls.<domain>, and
returns its report destinations. See parse_record/1.
NXDOMAIN and an empty answer are {:error, :no_record}; other DNS
failures are {:error, {:dns, reason}}.
@spec filename(String.t(), String.t(), DateTime.t(), DateTime.t(), String.t() | nil) :: String.t()
The file name of a report (RFC 8460 §5.1):
submitter!policy-domain!begin!end[!unique-id].json.gz, with the
times as Unix timestamps. The unique ID should be letters and digits;
nil or "" leaves it out.
@spec message(message_opts()) :: iodata()
Builds the report mail for mailto: destinations (RFC 8460 §5.3): a
multipart/report with report-type="tlsrpt", a short text, and the
gzipped report as an application/tlsrpt+gzip attachment.
:from- the sender address.:to- the destination addresses, withoutmailto:.:domain- the policy domain, and:submitter- the domain of the reporting organization; both go in theSubject:and theTLS-Report-Domain:andTLS-Report-Submitter:fields.:report_id,:filename,:gzip- the report's ID, file name (filename/5), and gzipped JSON.:hostname- the domain of the newMessage-ID:.:date,:message_id,:boundary- default to now, a new ID, and a random boundary.
Lines end in CRLF; long header fields are folded, and the attachment is base64 in lines of 76 characters.
@spec parse_record([String.t()]) :: {:ok, [String.t(), ...]} | {:error, record_error()}
Parses the TXT records found at _smtp._tls.<domain> (RFC 8460 §3)
and returns the rua URIs a report can be sent to, in order.
Only records starting with v=TLSRPTv1 count; the match is
case-sensitive, as the ABNF's %s says. There must be exactly one.
Fields are separated by ; with optional whitespace, and unknown
fields are ignored. Of the rua URIs, only mailto: and https:
ones are kept; if none is left, the record is invalid.
iex> Sovite.TLS.TLSRPT.parse_record(["v=TLSRPTv1; rua=mailto:tlsrpt@example.com"])
{:ok, ["mailto:tlsrpt@example.com"]}
@spec policy_type_name(policy_type()) :: String.t()
The name of a policy type in reports (RFC 8460 §4.4).
iex> Sovite.TLS.TLSRPT.policy_type_name(:no_policy_found)
"no-policy-found"
Sends a gzipped report to an https: destination (RFC 8460 §5.2): a
POST with Content-Type: application/tlsrpt+gzip. Any 2xx status is
success; redirects are not followed.
Options
:timeout- in milliseconds, for connecting and for the response. Defaults to 30 seconds.:cacerts- CAs to verify the server against. Defaults to the system's.:allow_http- also accept plainhttp:URLs, for testing. Defaults tofalse.
Errors
:invalid_url- not anhttps:URL with a host.{:http_status, status}- the server answered with a status other than 2xx.- an
:httpcerror, such as{:failed_connect, _}or:timeout.
Builds the JSON document of a report (RFC 8460 §4.4). Members come in
the RFC's order, so the output depends only on report. Times are
RFC 3339 in UTC, to the second; IP addresses are in text form.
nil members, empty policy-string and mx-host lists, and an empty
failure-details list are left out.
@spec result_type_name(result_type()) :: String.t()
The name of a result type in reports (RFC 8460 §4.3).
iex> Sovite.TLS.TLSRPT.result_type_name(:certificate_expired)
"certificate-expired"
@spec result_types() :: [result_type(), ...]
Returns every result type, in RFC 8460 §4.3 order.