Sovite.TLS.TLSRPT (sovite v0.6.0)

Copy Markdown View Source

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

failure()

@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.

message_opts()

@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.

policy()

@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.

policy_type()

@type policy_type() :: :tlsa | :sts | :no_policy_found

The kind of policy a report entry is about (RFC 8460 §4.4).

record_error()

@type record_error() :: :no_record | :multiple_records | :invalid_record

Why no report destinations were found.

report()

@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.

result_type()

@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

discover(resolver, domain)

@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}}.

filename(submitter, policy_domain, begin, finish, unique_id \\ nil)

@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.

message(opts)

@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, without mailto:.
  • :domain - the policy domain, and :submitter - the domain of the reporting organization; both go in the Subject: and the TLS-Report-Domain: and TLS-Report-Submitter: fields.
  • :report_id, :filename, :gzip - the report's ID, file name (filename/5), and gzipped JSON.
  • :hostname - the domain of the new Message-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.

parse_record(txts)

@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"]}

policy_type_name(atom)

@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"

post(url, gzip, opts \\ [])

@spec post(String.t(), binary(), keyword()) :: :ok | {:error, term()}

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 plain http: URLs, for testing. Defaults to false.

Errors

  • :invalid_url - not an https: URL with a host.
  • {:http_status, status} - the server answered with a status other than 2xx.
  • an :httpc error, such as {:failed_connect, _} or :timeout.

report(report)

@spec report(report()) :: binary()

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.

result_type_name(atom)

@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"

result_types()

@spec result_types() :: [result_type(), ...]

Returns every result type, in RFC 8460 §4.3 order.