SMTP MTA Strict Transport Security (MTA-STS, RFC 8461).
A domain announces a policy with a TXT record at _mta-sts.<domain>
and serves the policy itself over HTTPS:
_mta-sts.example.com. IN TXT "v=STSv1; id=20260101T000000"
# https://mta-sts.example.com/.well-known/mta-sts.txt
version: STSv1
mode: enforce
mx: mx1.example.com
mx: *.backup.example.com
max_age: 604800A sender that finds the record fetches the policy, caches it for
max_age seconds, and from then on delivers to the domain only over
TLS with a certificate valid for an MX host that match?/2 accepts.
The id tells it when to fetch again: it changes whenever the policy
does.
{:ok, id} = Sovite.TLS.MTASTS.discover(resolver, "example.com")
{:ok, policy} = Sovite.TLS.MTASTS.fetch("example.com")
Sovite.TLS.MTASTS.match?(policy, "mx1.example.com") #=> trueCaching, refreshing, and the TLS checks on the MX connection are left
to the caller. For receiving domains, policy_text/3 and policy_id/1
build the policy and its id, and Sovite.TLS.MTASTS.Server serves it.
Summary
Types
Why fetch/2 got no policy
Why parse_policy/1 rejected a policy
Why parse_record/1 or discover/2 found no usable record.
Functions
Looks up the TXT records at _mta-sts.<domain> and returns the policy
id, as parse_record/1 does.
Fetches and parses the policy for domain (RFC 8461 §3.3).
Returns whether the MX host host is allowed by policy (RFC 8461
§4.1).
Parses a policy body (RFC 8461 §3.2).
Finds the policy id in the TXT records at _mta-sts.<domain>.
Returns the host that serves the policy for domain.
Returns an id for the policy text, for the id= field of the
_mta-sts record: the first 20 hex digits of its SHA-256. It changes
exactly when the policy does, so senders refetch only then.
Builds a policy body, with CRLF line endings.
Returns the URL of the policy for domain.
Types
@type fetch_error() :: :invalid_domain | {:connect, term()} | {:tls, term()} | {:http_status, 100..999} | :invalid_content_type | :too_large | :timeout | {:invalid_response, atom()} | {:invalid_policy, policy_error()}
Why fetch/2 got no policy:
:invalid_domain- the domain is not a valid domain name.{:connect, reason}- the TCP connection failed (:inetreasons such as:econnrefusedor:nxdomain).{:tls, reason}- the TLS handshake failed, including a certificate that is not valid formta-sts.<domain>(:sslreasons). TLS-RPT (RFC 8460 §4.3.2.2) reports this assts-webpki-invalid.{:http_status, code}- the status was not 200. Redirects are not followed (RFC 8461 §3.3).:invalid_content_type- the Content-Type was nottext/plain.:too_large- the body was larger than:max_size.:timeout- the whole fetch took longer than:timeout.{:invalid_response, detail}- the HTTP response was malformed::status_line,:header,:headers_too_large,:content_length,:transfer_encoding,:chunk, or:truncated.{:invalid_policy, reason}- the body is not a valid policy, seepolicy_error/0.
@type policy_error() :: :invalid_encoding | {:invalid_line, pos_integer()} | :missing_version | {:invalid_version, String.t()} | :missing_mode | {:invalid_mode, String.t()} | :missing_max_age | {:invalid_max_age, String.t()} | :missing_mx | {:invalid_mx, String.t()}
Why parse_policy/1 rejected a policy:
:invalid_encoding- the body is not UTF-8.{:invalid_line, n}- linen(from 1) is notkey: value.:missing_version,{:invalid_version, value}-versionis missing or notSTSv1.:missing_mode,{:invalid_mode, value}-modeis missing or notenforce,testing, ornone.:missing_max_age,{:invalid_max_age, value}-max_ageis missing or not a number of at most 10 digits.:missing_mx- nomxline, with a mode other thannone.{:invalid_mx, value}- anmxvalue is neither a host name nor*.and a host name.
@type record_error() :: :no_record | :multiple_records | :invalid_record
Why parse_record/1 or discover/2 found no usable record.
Functions
@spec discover(Sovite.DNS.resolver(), String.t()) :: {:ok, id :: String.t()} | {:error, record_error() | {:dns, Sovite.DNS.Resolver.error()}}
Looks up the TXT records at _mta-sts.<domain> and returns the policy
id, as parse_record/1 does.
A name that does not exist and a name without TXT records both give
{:error, :no_record}. Other DNS errors give {:error, {:dns, reason}}: a sender then keeps using any cached policy (RFC 8461
§5.1).
@spec fetch(String.t(), keyword()) :: {:ok, Sovite.TLS.MTASTS.Policy.t()} | {:error, fetch_error()}
Fetches and parses the policy for domain (RFC 8461 §3.3).
Sends GET /.well-known/mta-sts.txt over HTTPS to
mta-sts.<domain>. The server's certificate must be valid for that
name (PKIX, with SNI). Only a 200 response with Content-Type
text/plain is accepted: redirects are not followed. The body may be
sent with Content-Length, chunked, or until the connection closes.
Options
:timeout- milliseconds for the whole fetch: connecting, the TLS handshake, and reading the response. Defaults to 60 seconds.:max_size- the largest body accepted, in bytes. Defaults to 65536.:cacerts- trusted CA certificates (DER). Defaults to the system's (:public_key.cacerts_get/0).:port- the TCP port. Defaults to 443.:connect_to- an{ip, port}tuple or a host name (charlist) to connect to instead ofmta-sts.<domain>, for tests and proxies. SNI and the certificate check still usemta-sts.<domain>.
See fetch_error/0 for the errors.
@spec match?(Sovite.TLS.MTASTS.Policy.t(), String.t()) :: boolean()
Returns whether the MX host host is allowed by policy (RFC 8461
§4.1).
Names are compared case-insensitively, ignoring a trailing dot. A
*.example.com pattern matches any name with exactly one more label
on the left: mx1.example.com, but neither example.com nor
a.b.example.com.
iex> policy = %Sovite.TLS.MTASTS.Policy{mode: :enforce, mx: ["*.example.com"], max_age: 86400, text: ""}
iex> Sovite.TLS.MTASTS.match?(policy, "MX1.example.com.")
true
iex> Sovite.TLS.MTASTS.match?(policy, "a.b.example.com")
false
@spec parse_policy(binary()) :: {:ok, Sovite.TLS.MTASTS.Policy.t()} | {:error, policy_error()}
Parses a policy body (RFC 8461 §3.2).
Lines end with LF or CRLF, and are key: value with optional
whitespace around the colon and at the ends. Blank lines and unknown
keys are ignored. Keys and the version and mode values are
case-sensitive.
versionmust beSTSv1.modemust beenforce,testing, ornone.max_agemust be a number of seconds; values above 31557600 are lowered to it.mxmay appear several times, and at least once unless the mode isnone. Each is a host name, or*.and a host name.
Only mx may repeat: for the other keys the first line counts.
iex> {:ok, policy} = Sovite.TLS.MTASTS.parse_policy("version: STSv1\nmode: enforce\nmx: MX.example.com\nmax_age: 86400\n")
iex> {policy.mode, policy.mx, policy.max_age}
{:enforce, ["mx.example.com"], 86400}
@spec parse_record([String.t()]) :: {:ok, id :: String.t()} | {:error, record_error()}
Finds the policy id in the TXT records at _mta-sts.<domain>.
Records that do not start with v=STSv1 are ignored. Exactly one must
be left, and it must be valid (RFC 8461 §3.1):
- fields are
key=value, separated by;with optional whitespace, and a trailing;is allowed; v=STSv1comes first;idis 1 to 32 letters and digits;- other fields are ignored, but no key may appear twice.
Otherwise the domain has no usable policy.
iex> Sovite.TLS.MTASTS.parse_record(["v=spf1 -all", "v=STSv1; id=20260101"])
{:ok, "20260101"}
iex> Sovite.TLS.MTASTS.parse_record(["v=STSv1; id=a", "v=STSv1; id=b"])
{:error, :multiple_records}
Returns the host that serves the policy for domain.
iex> Sovite.TLS.MTASTS.policy_host("example.com")
"mta-sts.example.com"
Returns an id for the policy text, for the id= field of the
_mta-sts record: the first 20 hex digits of its SHA-256. It changes
exactly when the policy does, so senders refetch only then.
@spec policy_text(Sovite.TLS.MTASTS.Policy.mode(), [String.t()], non_neg_integer()) :: String.t()
Builds a policy body, with CRLF line endings.
iex> Sovite.TLS.MTASTS.policy_text(:testing, ["mx.example.com"], 604800)
"version: STSv1\r\nmode: testing\r\nmx: mx.example.com\r\nmax_age: 604800\r\n"
Returns the URL of the policy for domain.
iex> Sovite.TLS.MTASTS.policy_url("example.com")
"https://mta-sts.example.com/.well-known/mta-sts.txt"