#17: implement DANE TLSA, MTA-STS policy resolution, ARC sealing and mail authentication
This commit is contained in:
1 parent
df8b809683
commit
bd0af98076
22 files changed
+3296
No files matched your search
@@ -0,0 +1,233 @@
|
||||
// Package tlspolicy resolves the outbound transport security policy of
|
||||
// PLAN.md section 6.2: DANE when the destination publishes DNSSEC-signed TLSA
|
||||
// records, MTA-STS when it publishes a policy over HTTPS, opportunistic TLS
|
||||
// otherwise.
|
||||
package tlspolicy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
)
|
||||
|
||||
// policyFetchLimit bounds the policy body. RFC 8461 policies are a few
|
||||
// hundred bytes; anything larger is a resource-exhaustion attempt.
|
||||
const policyFetchLimit = 64 * 1024
|
||||
|
||||
// Resolver implements port.TLSPolicyResolver.
|
||||
type Resolver struct {
|
||||
tlsa TlsaLookup
|
||||
httpClient *http.Client
|
||||
|
||||
daneEnabled bool
|
||||
mtaStsEnabled bool
|
||||
|
||||
mu sync.RWMutex
|
||||
cache map[string]cachedPolicy
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// TlsaLookup returns the DANE associations published for a host, and whether
|
||||
// the answer was DNSSEC-validated. An unvalidated answer must never be used:
|
||||
// unsigned TLSA records are trivially forged (PLAN.md section 5.1).
|
||||
type TlsaLookup interface {
|
||||
LookupTLSA(ctx context.Context, host string, port int) (records []valueobject.TLSARecord, authenticated bool, err error)
|
||||
}
|
||||
|
||||
type cachedPolicy struct {
|
||||
policy entity.TLSPolicy
|
||||
expiresAt time.Time
|
||||
// absent records that the destination publishes no MTA-STS policy, so
|
||||
// repeated sends do not re-probe it on every message.
|
||||
absent bool
|
||||
}
|
||||
|
||||
type Option func(*Resolver)
|
||||
|
||||
// WithDane turns DANE resolution on. PLAN.md section 5.1 keeps it off unless
|
||||
// the deployment manages its own certificates, because a TLSA record that
|
||||
// stops matching after an ACME renewal blackholes mail permanently.
|
||||
func WithDane(enabled bool, lookup TlsaLookup) Option {
|
||||
return func(r *Resolver) {
|
||||
r.daneEnabled = enabled
|
||||
r.tlsa = lookup
|
||||
}
|
||||
}
|
||||
|
||||
func WithMtaSts(enabled bool) Option {
|
||||
return func(r *Resolver) { r.mtaStsEnabled = enabled }
|
||||
}
|
||||
|
||||
func WithHTTPClient(client *http.Client) Option {
|
||||
return func(r *Resolver) { r.httpClient = client }
|
||||
}
|
||||
|
||||
func WithClock(now func() time.Time) Option {
|
||||
return func(r *Resolver) { r.now = now }
|
||||
}
|
||||
|
||||
func NewResolver(options ...Option) *Resolver {
|
||||
r := &Resolver{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
mtaStsEnabled: true,
|
||||
cache: map[string]cachedPolicy{},
|
||||
now: time.Now,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Resolver) Resolve(ctx context.Context, destinationDomain string, mxHost string) (entity.TLSPolicy, error) {
|
||||
// DANE first: it is authenticated by DNSSEC and outranks MTA-STS
|
||||
// (RFC 8461 section 2).
|
||||
if r.daneEnabled && r.tlsa != nil {
|
||||
records, authenticated, err := r.tlsa.LookupTLSA(ctx, mxHost, 25)
|
||||
if err != nil {
|
||||
return entity.TLSPolicy{}, fmt.Errorf("lookup TLSA for %s: %w", mxHost, err)
|
||||
}
|
||||
if len(records) > 0 {
|
||||
if !authenticated {
|
||||
// Unsigned TLSA records carry no security value and acting on
|
||||
// them would let an attacker choose our policy.
|
||||
return entity.TLSPolicy{}, fmt.Errorf("TLSA records for %s are not DNSSEC-validated", mxHost)
|
||||
}
|
||||
return entity.NewDanePolicy(records), nil
|
||||
}
|
||||
}
|
||||
|
||||
if !r.mtaStsEnabled {
|
||||
return entity.NewTLSPolicy(false, false), nil
|
||||
}
|
||||
|
||||
policy, found, err := r.mtaStsPolicy(ctx, destinationDomain)
|
||||
if err != nil {
|
||||
return entity.TLSPolicy{}, err
|
||||
}
|
||||
if !found {
|
||||
return entity.NewTLSPolicy(false, false), nil
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (r *Resolver) mtaStsPolicy(ctx context.Context, domain string) (entity.TLSPolicy, bool, error) {
|
||||
domain = strings.ToLower(strings.TrimSuffix(domain, "."))
|
||||
|
||||
if cached, ok := r.cached(domain); ok {
|
||||
return cached.policy, !cached.absent, nil
|
||||
}
|
||||
|
||||
policy, maxAge, found, err := r.fetchPolicy(ctx, domain)
|
||||
if err != nil {
|
||||
return entity.TLSPolicy{}, false, err
|
||||
}
|
||||
|
||||
// Cache the negative answer too, but only briefly: a domain that starts
|
||||
// publishing a policy should be picked up without a long delay.
|
||||
ttl := time.Duration(maxAge) * time.Second
|
||||
if !found {
|
||||
ttl = time.Hour
|
||||
}
|
||||
r.store(domain, cachedPolicy{policy: policy, expiresAt: r.now().Add(ttl), absent: !found})
|
||||
|
||||
return policy, found, nil
|
||||
}
|
||||
|
||||
// fetchPolicy retrieves and parses https://mta-sts.<domain>/.well-known/mta-sts.txt
|
||||
// (RFC 8461 section 3.3).
|
||||
func (r *Resolver) fetchPolicy(ctx context.Context, domain string) (policy entity.TLSPolicy, maxAge int, found bool, err error) {
|
||||
url := fmt.Sprintf("https://mta-sts.%s/.well-known/mta-sts.txt", domain)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return entity.TLSPolicy{}, 0, false, err
|
||||
}
|
||||
|
||||
resp, err := r.httpClient.Do(req)
|
||||
if err != nil {
|
||||
// A destination that publishes no policy host is simply not an
|
||||
// MTA-STS destination; that is not an error for us.
|
||||
return entity.TLSPolicy{}, 0, false, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return entity.TLSPolicy{}, 0, false, nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, policyFetchLimit))
|
||||
if err != nil {
|
||||
return entity.TLSPolicy{}, 0, false, nil
|
||||
}
|
||||
|
||||
mode, patterns, maxAge := parsePolicy(string(body))
|
||||
if mode == "" || len(patterns) == 0 {
|
||||
return entity.TLSPolicy{}, 0, false, nil
|
||||
}
|
||||
if mode == string(valueobject.MtaStsModeNone) {
|
||||
// An explicit "none" is the destination withdrawing its policy.
|
||||
return entity.TLSPolicy{}, 0, false, nil
|
||||
}
|
||||
|
||||
enforce := mode == string(valueobject.MtaStsModeEnforce)
|
||||
return entity.NewMtaStsPolicy(patterns, enforce), maxAge, true, nil
|
||||
}
|
||||
|
||||
// parsePolicy reads the key/value policy format of RFC 8461 section 3.2.
|
||||
func parsePolicy(body string) (mode string, mxPatterns []string, maxAge int) {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
key, value, found := strings.Cut(strings.TrimSpace(line), ":")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
key = strings.ToLower(strings.TrimSpace(key))
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
switch key {
|
||||
case "mode":
|
||||
mode = strings.ToLower(value)
|
||||
case "mx":
|
||||
mxPatterns = append(mxPatterns, value)
|
||||
case "max_age":
|
||||
maxAge, _ = strconv.Atoi(value)
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8461 section 3.2 caps max_age at 31557600 seconds; a missing or
|
||||
// absurd value falls back to a day so a stale policy cannot be pinned
|
||||
// forever by a malformed file.
|
||||
if maxAge <= 0 || maxAge > 31557600 {
|
||||
maxAge = 86400
|
||||
}
|
||||
return mode, mxPatterns, maxAge
|
||||
}
|
||||
|
||||
func (r *Resolver) cached(domain string) (cachedPolicy, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
entry, ok := r.cache[domain]
|
||||
if !ok || r.now().After(entry.expiresAt) {
|
||||
return cachedPolicy{}, false
|
||||
}
|
||||
return entry, true
|
||||
}
|
||||
|
||||
func (r *Resolver) store(domain string, entry cachedPolicy) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.cache[domain] = entry
|
||||
}
|
||||
|
||||
// Ensure port interface compliance
|
||||
var _ port.TLSPolicyResolver = (*Resolver)(nil)
|
||||
@@ -0,0 +1,222 @@
|
||||
package tlspolicy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/domain/entity"
|
||||
"lambdamail/protocols/internal/domain/valueobject"
|
||||
)
|
||||
|
||||
// policyServer stands in for https://mta-sts.<domain>/.well-known/mta-sts.txt.
|
||||
// The resolver builds that URL from the domain, so the test rewrites the
|
||||
// request through a custom transport instead of resolving real DNS.
|
||||
func policyServer(t *testing.T, body string, status int) (*http.Client, *int) {
|
||||
t.Helper()
|
||||
|
||||
hits := 0
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
if r.URL.Path != "/.well-known/mta-sts.txt" {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
client := server.Client()
|
||||
base := client.Transport.(*http.Transport).Clone()
|
||||
base.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
client.Transport = rewriteTransport{target: strings.TrimPrefix(server.URL, "https://"), inner: base}
|
||||
|
||||
return client, &hits
|
||||
}
|
||||
|
||||
type rewriteTransport struct {
|
||||
target string
|
||||
inner http.RoundTripper
|
||||
}
|
||||
|
||||
func (rt rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
clone := req.Clone(req.Context())
|
||||
clone.URL.Host = rt.target
|
||||
return rt.inner.RoundTrip(clone)
|
||||
}
|
||||
|
||||
type stubTlsaLookup struct {
|
||||
records []valueobject.TLSARecord
|
||||
authenticated bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (s stubTlsaLookup) LookupTLSA(_ context.Context, _ string, _ int) ([]valueobject.TLSARecord, bool, error) {
|
||||
return s.records, s.authenticated, s.err
|
||||
}
|
||||
|
||||
func TestResolver_MtaStsEnforcePolicyIsParsed(t *testing.T) {
|
||||
client, _ := policyServer(t, "version: STSv1\r\nmode: enforce\r\nmx: mx1.example.test\r\nmx: *.backup.test\r\nmax_age: 604800\r\n", http.StatusOK)
|
||||
|
||||
policy, err := NewResolver(WithHTTPClient(client)).
|
||||
Resolve(context.Background(), "example.test", "mx1.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
|
||||
if policy.Effective() != entity.TLSModeMtaSts {
|
||||
t.Errorf("Effective = %q, want mta-sts", policy.Effective())
|
||||
}
|
||||
if !policy.RequiresValidation() {
|
||||
t.Error("an enforce policy must require validation")
|
||||
}
|
||||
if !policy.CoversHost("mx1.example.test") || !policy.CoversHost("a.backup.test") {
|
||||
t.Error("the mx patterns were not carried into the policy")
|
||||
}
|
||||
if policy.CoversHost("mx.evil.test") {
|
||||
t.Error("an unlisted host must not be covered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_MtaStsTestingModeDoesNotEnforce(t *testing.T) {
|
||||
client, _ := policyServer(t, "version: STSv1\nmode: testing\nmx: mx1.example.test\nmax_age: 86400\n", http.StatusOK)
|
||||
|
||||
policy, err := NewResolver(WithHTTPClient(client)).
|
||||
Resolve(context.Background(), "example.test", "mx1.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if policy.RequiresValidation() {
|
||||
t.Error("mode: testing must not enforce")
|
||||
}
|
||||
}
|
||||
|
||||
// A destination with no policy endpoint is opportunistic, which is not an
|
||||
// error: most of the internet is in this state.
|
||||
func TestResolver_MissingPolicyIsOpportunistic(t *testing.T) {
|
||||
client, _ := policyServer(t, "", http.StatusNotFound)
|
||||
|
||||
policy, err := NewResolver(WithHTTPClient(client)).
|
||||
Resolve(context.Background(), "example.test", "mx1.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if policy.Effective() != entity.TLSModeOpportunistic {
|
||||
t.Errorf("Effective = %q, want opportunistic", policy.Effective())
|
||||
}
|
||||
}
|
||||
|
||||
// RFC 8461 section 3.2 caches the policy for max_age; re-fetching on every
|
||||
// message would hammer the destination and slow every send.
|
||||
func TestResolver_CachesPolicyForMaxAge(t *testing.T) {
|
||||
client, hits := policyServer(t, "version: STSv1\nmode: enforce\nmx: mx1.example.test\nmax_age: 604800\n", http.StatusOK)
|
||||
resolver := NewResolver(WithHTTPClient(client))
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := resolver.Resolve(ctx, "example.test", "mx1.example.test"); err != nil {
|
||||
t.Fatalf("Resolve %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
if *hits != 1 {
|
||||
t.Errorf("fetched the policy %d times, want 1", *hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolver_RefetchesAfterCacheExpiry(t *testing.T) {
|
||||
client, hits := policyServer(t, "version: STSv1\nmode: enforce\nmx: mx1.example.test\nmax_age: 60\n", http.StatusOK)
|
||||
|
||||
now := time.Now()
|
||||
resolver := NewResolver(WithHTTPClient(client), WithClock(func() time.Time { return now }))
|
||||
ctx := context.Background()
|
||||
|
||||
resolver.Resolve(ctx, "example.test", "mx1.example.test")
|
||||
now = now.Add(2 * time.Minute)
|
||||
resolver.Resolve(ctx, "example.test", "mx1.example.test")
|
||||
|
||||
if *hits != 2 {
|
||||
t.Errorf("fetched the policy %d times, want 2 (cache expired between calls)", *hits)
|
||||
}
|
||||
}
|
||||
|
||||
// An explicit "mode: none" is the destination withdrawing its policy.
|
||||
func TestResolver_ModeNoneIsOpportunistic(t *testing.T) {
|
||||
client, _ := policyServer(t, "version: STSv1\nmode: none\nmx: mx1.example.test\nmax_age: 86400\n", http.StatusOK)
|
||||
|
||||
policy, _ := NewResolver(WithHTTPClient(client)).
|
||||
Resolve(context.Background(), "example.test", "mx1.example.test")
|
||||
if policy.Effective() != entity.TLSModeOpportunistic {
|
||||
t.Errorf("Effective = %q, want opportunistic", policy.Effective())
|
||||
}
|
||||
}
|
||||
|
||||
// DANE outranks MTA-STS, so a destination with TLSA records never reaches the
|
||||
// HTTPS fetch (RFC 8461 section 2).
|
||||
func TestResolver_DaneWinsAndSkipsMtaStsFetch(t *testing.T) {
|
||||
client, hits := policyServer(t, "version: STSv1\nmode: enforce\nmx: mx1.example.test\nmax_age: 86400\n", http.StatusOK)
|
||||
record, _ := valueobject.NewTLSARecord(3, 1, 1, strings.Repeat("ab", 32))
|
||||
|
||||
policy, err := NewResolver(
|
||||
WithHTTPClient(client),
|
||||
WithDane(true, stubTlsaLookup{records: []valueobject.TLSARecord{record}, authenticated: true}),
|
||||
).Resolve(context.Background(), "example.test", "mx1.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
|
||||
if policy.Effective() != entity.TLSModeDane {
|
||||
t.Errorf("Effective = %q, want dane", policy.Effective())
|
||||
}
|
||||
if *hits != 0 {
|
||||
t.Error("the MTA-STS policy was fetched even though DANE applies")
|
||||
}
|
||||
}
|
||||
|
||||
// Acting on unsigned TLSA records would let anyone who can spoof DNS choose
|
||||
// our transport policy (PLAN.md section 5.1).
|
||||
func TestResolver_RefusesUnauthenticatedTlsaRecords(t *testing.T) {
|
||||
record, _ := valueobject.NewTLSARecord(3, 1, 1, strings.Repeat("ab", 32))
|
||||
|
||||
_, err := NewResolver(
|
||||
WithDane(true, stubTlsaLookup{records: []valueobject.TLSARecord{record}, authenticated: false}),
|
||||
).Resolve(context.Background(), "example.test", "mx1.example.test")
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected unsigned TLSA records to be refused")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "DNSSEC") {
|
||||
t.Errorf("error should explain the DNSSEC requirement: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A destination with DANE disabled must fall through to MTA-STS.
|
||||
func TestResolver_DaneDisabledFallsBackToMtaSts(t *testing.T) {
|
||||
client, _ := policyServer(t, "version: STSv1\nmode: enforce\nmx: mx1.example.test\nmax_age: 86400\n", http.StatusOK)
|
||||
|
||||
policy, err := NewResolver(WithHTTPClient(client), WithDane(false, stubTlsaLookup{})).
|
||||
Resolve(context.Background(), "example.test", "mx1.example.test")
|
||||
if err != nil {
|
||||
t.Fatalf("Resolve: %v", err)
|
||||
}
|
||||
if policy.Effective() != entity.TLSModeMtaSts {
|
||||
t.Errorf("Effective = %q, want mta-sts", policy.Effective())
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePolicy_ClampsAbsurdMaxAge(t *testing.T) {
|
||||
_, _, maxAge := parsePolicy("mode: enforce\nmx: a.test\nmax_age: 999999999999\n")
|
||||
if maxAge != 86400 {
|
||||
t.Errorf("maxAge = %d, want the 86400 fallback", maxAge)
|
||||
}
|
||||
|
||||
_, _, missing := parsePolicy("mode: enforce\nmx: a.test\n")
|
||||
if missing != 86400 {
|
||||
t.Errorf("maxAge = %d, want the 86400 fallback", missing)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user