Sovite.TLS.MTASTS (sovite v0.6.0)

Copy Markdown View Source

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: 604800

A 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")  #=> true

Caching, 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

fetch_error()

@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 (:inet reasons such as :econnrefused or :nxdomain).
  • {:tls, reason} - the TLS handshake failed, including a certificate that is not valid for mta-sts.<domain> (:ssl reasons). TLS-RPT (RFC 8460 §4.3.2.2) reports this as sts-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 not text/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, see policy_error/0.

policy_error()

@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} - line n (from 1) is not key: value.
  • :missing_version, {:invalid_version, value} - version is missing or not STSv1.
  • :missing_mode, {:invalid_mode, value} - mode is missing or not enforce, testing, or none.
  • :missing_max_age, {:invalid_max_age, value} - max_age is missing or not a number of at most 10 digits.
  • :missing_mx - no mx line, with a mode other than none.
  • {:invalid_mx, value} - an mx value is neither a host name nor *. and a host name.

record_error()

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

Why parse_record/1 or discover/2 found no usable record.

Functions

discover(resolver, domain)

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

fetch(domain, opts \\ [])

@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 of mta-sts.<domain>, for tests and proxies. SNI and the certificate check still use mta-sts.<domain>.

See fetch_error/0 for the errors.

match?(policy, host)

@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

parse_policy(text)

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

  • version must be STSv1.
  • mode must be enforce, testing, or none.
  • max_age must be a number of seconds; values above 31557600 are lowered to it.
  • mx may appear several times, and at least once unless the mode is none. 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}

parse_record(txts)

@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=STSv1 comes first;
  • id is 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}

policy_host(domain)

@spec policy_host(String.t()) :: String.t()

Returns the host that serves the policy for domain.

iex> Sovite.TLS.MTASTS.policy_host("example.com")
"mta-sts.example.com"

policy_id(text)

@spec policy_id(String.t()) :: String.t()

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.

policy_text(mode, mx, max_age)

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"

policy_url(domain)

@spec policy_url(String.t()) :: String.t()

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"