#17: implement DANE TLSA, MTA-STS policy resolution, ARC sealing and mail authentication

This commit is contained in:
Lucas Rayan committed 2026-08-01 22:48:38 -03:00
1 parent df8b809683
commit bd0af98076
22 files changed
+3296

No files matched your search

@@ -0,0 +1,147 @@
// Package arc implements Authenticated Received Chain (RFC 8617).
//
// ARC exists for the case DKIM cannot cover: a mailing list or forwarder that
// legitimately modifies a message breaks its DKIM signature, and the receiving
// domain then sees a DMARC failure for mail that was authentic when it was
// sent. An ARC chain records each intermediary's own authentication verdict
// and seals it, so the final receiver can see that the message authenticated
// correctly at the first hop (PLAN.md section 5).
//
// Nothing in the Go ecosystem provides ARC, so the signature construction is
// implemented here on top of the same relaxed canonicalization DKIM uses
// (RFC 6376 section 3.4.2), which RFC 8617 mandates for both ARC signatures.
package arc
import (
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"strings"
)
// Header names defined by RFC 8617 section 4.1.
const (
HeaderAuthenticationResults = "ARC-Authentication-Results"
HeaderMessageSignature = "ARC-Message-Signature"
HeaderSeal = "ARC-Seal"
)
// header is one parsed header field, kept in the order it appeared.
type header struct {
name string
value string
raw string
}
// canonicalizeHeaderRelaxed renders a header field the way RFC 6376 section
// 3.4.2 prescribes: lowercase name, whitespace runs folded to a single space,
// no space around the colon, no trailing whitespace.
func canonicalizeHeaderRelaxed(name, value string) string {
name = strings.ToLower(strings.TrimSpace(name))
// Unfold: a continuation line is part of the same field value.
value = strings.ReplaceAll(value, "\r\n", "")
value = strings.ReplaceAll(value, "\n", "")
value = strings.Join(strings.Fields(value), " ")
return name + ":" + value
}
// canonicalizeBodyRelaxed applies RFC 6376 section 3.4.4: trailing whitespace
// removed from each line, internal whitespace runs collapsed, trailing empty
// lines removed, and a single CRLF kept when the body is not empty.
func canonicalizeBodyRelaxed(body string) string {
body = strings.ReplaceAll(body, "\r\n", "\n")
lines := strings.Split(body, "\n")
for i, line := range lines {
lines[i] = strings.TrimRight(strings.Join(strings.Fields(line), " "), " \t")
}
// Drop trailing empty lines.
end := len(lines)
for end > 0 && lines[end-1] == "" {
end--
}
lines = lines[:end]
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\r\n") + "\r\n"
}
// bodyHash is the "bh=" tag: the base64 SHA-256 of the canonicalized body.
func bodyHash(body string) string {
sum := sha256.Sum256([]byte(canonicalizeBodyRelaxed(body)))
return base64.StdEncoding.EncodeToString(sum[:])
}
// splitMessage separates the header block from the body, keeping the headers
// in order and with their continuation lines joined.
func splitMessage(message string) (headers []header, body string) {
normalized := strings.ReplaceAll(message, "\r\n", "\n")
headerBlock, body, found := strings.Cut(normalized, "\n\n")
if !found {
headerBlock = normalized
body = ""
}
var current *header
for _, line := range strings.Split(headerBlock, "\n") {
if line == "" {
continue
}
// A line starting with whitespace continues the previous field.
if (line[0] == ' ' || line[0] == '\t') && current != nil {
current.value += " " + strings.TrimSpace(line)
current.raw += "\r\n" + line
continue
}
name, value, ok := strings.Cut(line, ":")
if !ok {
continue
}
headers = append(headers, header{
name: strings.TrimSpace(name),
value: strings.TrimSpace(value),
raw: line,
})
current = &headers[len(headers)-1]
}
return headers, body
}
// sign produces the base64 signature over data for either supported key type.
func sign(signer crypto.Signer, data []byte) (string, error) {
// RFC 8617 section 4.1.2 allows rsa-sha256 and, following RFC 8463,
// ed25519-sha256. Ed25519 signs the message itself; RSA signs its digest.
if _, isEd25519 := signer.Public().(ed25519.PublicKey); isEd25519 {
signature, err := signer.Sign(rand.Reader, data, crypto.Hash(0))
if err != nil {
return "", fmt.Errorf("ed25519 sign: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
digest := sha256.Sum256(data)
signature, err := signer.Sign(rand.Reader, digest[:], crypto.SHA256)
if err != nil {
return "", fmt.Errorf("rsa sign: %w", err)
}
return base64.StdEncoding.EncodeToString(signature), nil
}
// algorithmFor names the algorithm in the "a=" tag.
func algorithmFor(signer crypto.Signer) string {
if _, isEd25519 := signer.Public().(ed25519.PublicKey); isEd25519 {
return "ed25519-sha256"
}
return "rsa-sha256"
}
@@ -0,0 +1,268 @@
package arc
import (
"context"
"crypto"
"fmt"
"strconv"
"strings"
"time"
"lambdamail/protocols/internal/application/port"
)
// maxChainInstance caps the chain length. RFC 8617 section 5.1.1 fixes the
// maximum instance number at 50; beyond it the chain must be treated as
// failed rather than extended.
const maxChainInstance = 50
// signedHeaders are the fields the ARC-Message-Signature covers. It mirrors
// the DKIM set: headers a forwarder is likely to add later stay out, or the
// signature would break for the very case ARC exists to handle.
var signedHeaders = []string{
"from", "to", "cc", "subject", "date", "message-id",
"mime-version", "content-type", "content-transfer-encoding",
"in-reply-to", "references",
}
// ChainValidation is the "cv=" tag: the state of the chain as it arrived.
type ChainValidation string
const (
// ChainNone means no ARC chain was present, so this hop starts one.
ChainNone ChainValidation = "none"
// ChainPass means the chain arrived intact.
ChainPass ChainValidation = "pass"
// ChainFail means the chain is broken. RFC 8617 section 5.1.2 forbids
// extending a failed chain, so sealing stops here.
ChainFail ChainValidation = "fail"
)
// Sealer adds an ARC set to a message being forwarded.
type Sealer struct {
// domain and selector identify the key, and are the same identity DKIM
// signs with: an ARC seal is verified through the same DNS record.
domain string
selector string
signer crypto.Signer
// authServID names this server in the ARC-Authentication-Results header.
authServID string
now func() time.Time
}
func NewSealer(domain, selector, authServID string, signer crypto.Signer) *Sealer {
if authServID == "" {
authServID = domain
}
return &Sealer{
domain: domain,
selector: selector,
signer: signer,
authServID: authServID,
now: time.Now,
}
}
// WithClock fixes the timestamp, so a test can assert on exact output.
func (s *Sealer) WithClock(now func() time.Time) *Sealer {
s.now = now
return s
}
// Seal prepends one ARC set - ARC-Authentication-Results, then
// ARC-Message-Signature, then ARC-Seal - to the message.
//
// The order matters and is prescribed by RFC 8617 section 5.1.1: the seal is
// computed last, over the other two, and is placed above them.
func (s *Sealer) Seal(_ context.Context, message []byte, authResult port.InboundAuthResult) ([]byte, error) {
text := string(message)
headers, body := splitMessage(text)
instance, chainState := s.inspectChain(headers)
if chainState == ChainFail {
// Extending a failed chain is forbidden; the message is forwarded as
// it is so the receiver can see the break for itself.
return message, nil
}
if instance > maxChainInstance {
return message, nil
}
timestamp := s.now().Unix()
// 1. ARC-Authentication-Results records what this hop concluded.
aar := fmt.Sprintf("i=%d; %s", instance, s.formatAuthResults(authResult))
// 2. ARC-Message-Signature covers the message, exactly as a DKIM
// signature would, but never covers the ARC-Seal headers.
ams, err := s.buildMessageSignature(instance, timestamp, headers, body)
if err != nil {
return nil, err
}
// 3. ARC-Seal covers the whole chain including the two headers above.
seal, err := s.buildSeal(instance, timestamp, chainState, headers, aar, ams)
if err != nil {
return nil, err
}
var out strings.Builder
out.WriteString(HeaderSeal + ": " + seal + "\r\n")
out.WriteString(HeaderMessageSignature + ": " + ams + "\r\n")
out.WriteString(HeaderAuthenticationResults + ": " + aar + "\r\n")
out.WriteString(text)
return []byte(out.String()), nil
}
// inspectChain finds the instance number this hop should use and the state of
// the chain that arrived.
func (s *Sealer) inspectChain(headers []header) (instance int, state ChainValidation) {
highest := 0
previousCV := ""
for _, h := range headers {
if !strings.EqualFold(h.name, HeaderSeal) {
continue
}
tags := parseTags(h.value)
if i, err := strconv.Atoi(tags["i"]); err == nil && i > highest {
highest = i
previousCV = tags["cv"]
}
}
if highest == 0 {
// No chain yet: this hop is instance 1 and reports "none", which is
// what RFC 8617 section 5.1.1 requires of the first sealer.
return 1, ChainNone
}
if previousCV == string(ChainFail) {
return highest + 1, ChainFail
}
return highest + 1, ChainPass
}
// buildMessageSignature produces the AMS value. It is a DKIM signature in all
// but name, so the tag set and canonicalization match RFC 6376.
func (s *Sealer) buildMessageSignature(instance int, timestamp int64, headers []header, body string) (string, error) {
present := signedHeaderList(headers)
unsigned := fmt.Sprintf(
"i=%d; a=%s; c=relaxed/relaxed; d=%s; s=%s; t=%d; h=%s; bh=%s; b=",
instance, algorithmFor(s.signer), s.domain, s.selector, timestamp,
strings.Join(present, ":"), bodyHash(body),
)
material := canonicalHeaderBlock(headers, present)
// The signature covers its own header with an empty "b=" tag
// (RFC 6376 section 3.7).
material += canonicalizeHeaderRelaxed(HeaderMessageSignature, unsigned)
signature, err := sign(s.signer, []byte(material))
if err != nil {
return "", err
}
return unsigned + signature, nil
}
// buildSeal produces the AS value. Unlike the message signature it covers no
// body and no message headers: only the ARC set headers, in instance order
// (RFC 8617 section 5.1.1).
func (s *Sealer) buildSeal(instance int, timestamp int64, state ChainValidation, headers []header, aar, ams string) (string, error) {
unsigned := fmt.Sprintf(
"i=%d; a=%s; cv=%s; d=%s; s=%s; t=%d; b=",
instance, algorithmFor(s.signer), state, s.domain, s.selector, timestamp,
)
var material strings.Builder
// Every previous ARC set, oldest first.
for i := 1; i < instance; i++ {
for _, name := range []string{HeaderAuthenticationResults, HeaderMessageSignature, HeaderSeal} {
if value, ok := findInstanceHeader(headers, name, i); ok {
material.WriteString(canonicalizeHeaderRelaxed(name, value))
}
}
}
// Then this hop's own set, with the seal itself carrying an empty "b=".
material.WriteString(canonicalizeHeaderRelaxed(HeaderAuthenticationResults, aar))
material.WriteString(canonicalizeHeaderRelaxed(HeaderMessageSignature, ams))
material.WriteString(canonicalizeHeaderRelaxed(HeaderSeal, unsigned))
signature, err := sign(s.signer, []byte(material.String()))
if err != nil {
return "", err
}
return unsigned + signature, nil
}
// formatAuthResults renders this hop's verdicts for the AAR header.
func (s *Sealer) formatAuthResults(result port.InboundAuthResult) string {
if result.AuthenticationResults != "" {
return result.AuthenticationResults
}
return fmt.Sprintf("%s; spf=%s; dkim=%s; dmarc=%s",
s.authServID, orNone(result.SPF), orNone(result.DKIM), orNone(result.DMARC))
}
func orNone(value string) string {
if value == "" {
return "none"
}
return value
}
// signedHeaderList keeps only the headers actually present, in the order the
// "h=" tag will list them.
func signedHeaderList(headers []header) []string {
var present []string
for _, candidate := range signedHeaders {
for _, h := range headers {
if strings.EqualFold(h.name, candidate) {
present = append(present, candidate)
break
}
}
}
return present
}
// canonicalHeaderBlock renders the signed headers in "h=" order.
func canonicalHeaderBlock(headers []header, names []string) string {
var sb strings.Builder
for _, name := range names {
for _, h := range headers {
if strings.EqualFold(h.name, name) {
sb.WriteString(canonicalizeHeaderRelaxed(h.name, h.value))
break
}
}
}
return sb.String()
}
// findInstanceHeader locates the ARC header of a given name and instance.
func findInstanceHeader(headers []header, name string, instance int) (string, bool) {
for _, h := range headers {
if !strings.EqualFold(h.name, name) {
continue
}
if parseTags(h.value)["i"] == strconv.Itoa(instance) {
return h.value, true
}
}
return "", false
}
// parseTags reads the "tag=value; tag=value" form ARC and DKIM headers use.
func parseTags(value string) map[string]string {
tags := map[string]string{}
for _, part := range strings.Split(value, ";") {
key, val, ok := strings.Cut(part, "=")
if !ok {
continue
}
tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(val)
}
return tags
}
@@ -0,0 +1,258 @@
package arc
import (
"context"
"crypto"
"fmt"
"strings"
"testing"
"time"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/infrastructure/dkim"
)
const forwardedMessage = "From: author@origin.test\r\n" +
"To: list@forwarder.test\r\n" +
"Subject: hello\r\n" +
"Date: Mon, 20 Jul 2026 10:00:00 +0000\r\n" +
"Message-ID: <m1@origin.test>\r\n" +
"\r\n" +
"the body\r\n"
// keyring resolves the public keys of every sealing domain in a test.
type keyring map[string]crypto.PublicKey
func (k keyring) lookup(domain, selector string) (crypto.PublicKey, error) {
key, ok := k[domain+"/"+selector]
if !ok {
return nil, fmt.Errorf("no key for %s/%s", domain, selector)
}
return key, nil
}
func newSealer(t *testing.T, domain, algorithm string, ring keyring) *Sealer {
t.Helper()
generated, err := dkim.Generate(algorithm)
if err != nil {
t.Fatalf("Generate: %v", err)
}
signer, err := dkim.ParsePrivateKey(generated.PrivateKeyPEM)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
ring[domain+"/default"] = signer.Public()
fixed := time.Date(2026, 7, 20, 10, 0, 0, 0, time.UTC)
return NewSealer(domain, "default", "mx."+domain, signer).
WithClock(func() time.Time { return fixed })
}
func passingResult() port.InboundAuthResult {
return port.InboundAuthResult{
SPF: "pass", DKIM: "pass", DMARC: "pass",
AuthenticationResults: "mx.forwarder.test; spf=pass; dkim=pass; dmarc=pass",
}
}
// A first sealer starts the chain: instance 1 with cv=none
// (RFC 8617 section 5.1.1).
func TestSealer_StartsChainAtInstanceOneWithCvNone(t *testing.T) {
ring := keyring{}
sealed, err := newSealer(t, "forwarder.test", dkim.AlgorithmRSA2048, ring).
Seal(context.Background(), []byte(forwardedMessage), passingResult())
if err != nil {
t.Fatalf("Seal: %v", err)
}
out := string(sealed)
for _, name := range []string{HeaderSeal, HeaderMessageSignature, HeaderAuthenticationResults} {
if !strings.Contains(out, name+":") {
t.Errorf("missing %s:\n%s", name, out)
}
}
if !strings.Contains(out, "cv=none") {
t.Errorf("first seal must report cv=none:\n%s", out)
}
if !strings.Contains(out, "i=1") {
t.Errorf("first seal must be instance 1:\n%s", out)
}
if !strings.Contains(out, "d=forwarder.test") {
t.Errorf("seal does not claim the sealing domain:\n%s", out)
}
// The original message must survive untouched below the new headers.
if !strings.HasSuffix(out, forwardedMessage) {
t.Error("the original message was modified")
}
}
// The header order is prescribed: the seal is computed last and sits on top.
func TestSealer_WritesHeadersInPrescribedOrder(t *testing.T) {
ring := keyring{}
sealed, _ := newSealer(t, "forwarder.test", dkim.AlgorithmRSA2048, ring).
Seal(context.Background(), []byte(forwardedMessage), passingResult())
out := string(sealed)
sealAt := strings.Index(out, HeaderSeal+":")
amsAt := strings.Index(out, HeaderMessageSignature+":")
aarAt := strings.Index(out, HeaderAuthenticationResults+":")
if !(sealAt < amsAt && amsAt < aarAt) {
t.Errorf("header order is seal=%d ams=%d aar=%d, want seal first", sealAt, amsAt, aarAt)
}
}
// The whole point: a chain we sealed must verify.
func TestSealer_ProducesVerifiableChain(t *testing.T) {
for _, algorithm := range []string{dkim.AlgorithmRSA2048, dkim.AlgorithmEd25519} {
t.Run(algorithm, func(t *testing.T) {
ring := keyring{}
sealed, err := newSealer(t, "forwarder.test", algorithm, ring).
Seal(context.Background(), []byte(forwardedMessage), passingResult())
if err != nil {
t.Fatalf("Seal: %v", err)
}
state, err := VerifyChain(sealed, ring.lookup)
if err != nil {
t.Fatalf("VerifyChain: %v", err)
}
if state != ChainPass {
t.Errorf("chain state = %s, want pass", state)
}
})
}
}
// A second forwarder extends the chain, and both sets must still verify.
func TestSealer_ExtendsExistingChain(t *testing.T) {
ring := keyring{}
first, err := newSealer(t, "first.test", dkim.AlgorithmRSA2048, ring).
Seal(context.Background(), []byte(forwardedMessage), passingResult())
if err != nil {
t.Fatalf("first Seal: %v", err)
}
second, err := newSealer(t, "second.test", dkim.AlgorithmEd25519, ring).
Seal(context.Background(), first, passingResult())
if err != nil {
t.Fatalf("second Seal: %v", err)
}
out := string(second)
if !strings.Contains(out, "i=2") {
t.Errorf("the second hop did not take instance 2:\n%s", out)
}
// The second hop saw an intact chain, so it reports cv=pass.
if !strings.Contains(out, "cv=pass") {
t.Errorf("the second seal should report cv=pass:\n%s", out)
}
if count := strings.Count(out, HeaderSeal+":"); count != 2 {
t.Errorf("found %d seals, want 2", count)
}
state, err := VerifyChain(second, ring.lookup)
if err != nil {
t.Fatalf("VerifyChain: %v", err)
}
if state != ChainPass {
t.Errorf("chain state = %s, want pass", state)
}
}
// Modifying the body after sealing must break the message signature - this is
// what proves the body hash is actually covering anything.
func TestVerifyChain_DetectsBodyTampering(t *testing.T) {
ring := keyring{}
sealed, _ := newSealer(t, "forwarder.test", dkim.AlgorithmRSA2048, ring).
Seal(context.Background(), []byte(forwardedMessage), passingResult())
tampered := strings.Replace(string(sealed), "the body", "evil body", 1)
state, err := VerifyChain([]byte(tampered), ring.lookup)
if err == nil {
t.Fatal("expected tampering to be detected")
}
if state != ChainFail {
t.Errorf("chain state = %s, want fail", state)
}
}
// Modifying a signed header must break the signature too.
func TestVerifyChain_DetectsHeaderTampering(t *testing.T) {
ring := keyring{}
sealed, _ := newSealer(t, "forwarder.test", dkim.AlgorithmRSA2048, ring).
Seal(context.Background(), []byte(forwardedMessage), passingResult())
tampered := strings.Replace(string(sealed), "Subject: hello", "Subject: forged", 1)
if state, err := VerifyChain([]byte(tampered), ring.lookup); err == nil || state != ChainFail {
t.Errorf("state = %s, err = %v; want a detected failure", state, err)
}
}
// A message with no chain is "none", not a failure.
func TestVerifyChain_NoChainIsNone(t *testing.T) {
state, err := VerifyChain([]byte(forwardedMessage), keyring{}.lookup)
if err != nil {
t.Fatalf("VerifyChain: %v", err)
}
if state != ChainNone {
t.Errorf("state = %s, want none", state)
}
}
// RFC 8617 section 5.1.2: a broken chain must not be extended.
func TestSealer_RefusesToExtendFailedChain(t *testing.T) {
ring := keyring{}
failed := "ARC-Seal: i=1; a=rsa-sha256; cv=fail; d=broken.test; s=default; t=1; b=AAAA\r\n" +
forwardedMessage
sealed, err := newSealer(t, "forwarder.test", dkim.AlgorithmRSA2048, ring).
Seal(context.Background(), []byte(failed), passingResult())
if err != nil {
t.Fatalf("Seal: %v", err)
}
if string(sealed) != failed {
t.Error("a failed chain was extended instead of being left alone")
}
}
// The relaxed body canonicalization of RFC 6376 section 3.4.4 must ignore
// trailing whitespace and trailing empty lines, so cosmetic changes in transit
// do not break the signature.
func TestBodyHash_IgnoresCosmeticWhitespace(t *testing.T) {
base := bodyHash("line one\r\nline two\r\n")
equivalent := []string{
"line one \r\nline two\r\n",
"line one\r\nline two\r\n\r\n\r\n",
"line one\r\nline two\r\n",
}
for _, variant := range equivalent {
if bodyHash(variant) != base {
t.Errorf("body hash changed for a cosmetically equivalent body: %q", variant)
}
}
if bodyHash("line one\r\nline three\r\n") == base {
t.Error("body hash did not change for genuinely different content")
}
}
func TestCanonicalizeHeaderRelaxed(t *testing.T) {
cases := map[string]string{
"Subject: hello": "subject:hello",
"SUBJECT: hello ": "subject:hello",
"Subject:\thello there": "subject:hello there",
}
for input, want := range cases {
name, value, _ := strings.Cut(input, ":")
if got := canonicalizeHeaderRelaxed(name, value); got != want {
t.Errorf("canonicalizeHeaderRelaxed(%q) = %q, want %q", input, got, want)
}
}
}
@@ -0,0 +1,135 @@
package arc
import (
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"fmt"
"strconv"
"strings"
)
// VerifyChain checks an ARC chain against the public keys of the sealing
// domains and returns the chain validation state a downstream hop should
// report in its own "cv=" tag.
//
// publicKeyFor resolves the key for a domain and selector; it is injected so
// the caller decides how lookups happen (DNS in production, a fixed map in a
// test).
func VerifyChain(message []byte, publicKeyFor func(domain, selector string) (crypto.PublicKey, error)) (ChainValidation, error) {
headers, body := splitMessage(string(message))
highest := 0
for _, h := range headers {
if strings.EqualFold(h.name, HeaderSeal) {
if i, err := strconv.Atoi(parseTags(h.value)["i"]); err == nil && i > highest {
highest = i
}
}
}
if highest == 0 {
return ChainNone, nil
}
if highest > maxChainInstance {
return ChainFail, fmt.Errorf("arc: chain exceeds the maximum of %d instances", maxChainInstance)
}
// RFC 8617 section 5.2: the most recent ARC-Message-Signature must
// validate, and every ARC-Seal in the chain must validate.
if err := verifyMessageSignature(headers, body, highest, publicKeyFor); err != nil {
return ChainFail, err
}
for instance := 1; instance <= highest; instance++ {
if err := verifySeal(headers, instance, publicKeyFor); err != nil {
return ChainFail, err
}
}
return ChainPass, nil
}
func verifyMessageSignature(headers []header, body string, instance int, publicKeyFor func(string, string) (crypto.PublicKey, error)) error {
value, ok := findInstanceHeader(headers, HeaderMessageSignature, instance)
if !ok {
return fmt.Errorf("arc: no %s for instance %d", HeaderMessageSignature, instance)
}
tags := parseTags(value)
if tags["bh"] != bodyHash(body) {
return fmt.Errorf("arc: body hash mismatch at instance %d", instance)
}
signedNames := strings.Split(tags["h"], ":")
material := canonicalHeaderBlock(headers, signedNames)
material += canonicalizeHeaderRelaxed(HeaderMessageSignature, stripSignature(value))
return verifySignature(tags, []byte(material), publicKeyFor)
}
func verifySeal(headers []header, instance int, publicKeyFor func(string, string) (crypto.PublicKey, error)) error {
value, ok := findInstanceHeader(headers, HeaderSeal, instance)
if !ok {
return fmt.Errorf("arc: no %s for instance %d", HeaderSeal, instance)
}
var material strings.Builder
for i := 1; i < instance; i++ {
for _, name := range []string{HeaderAuthenticationResults, HeaderMessageSignature, HeaderSeal} {
if previous, ok := findInstanceHeader(headers, name, i); ok {
material.WriteString(canonicalizeHeaderRelaxed(name, previous))
}
}
}
if aar, ok := findInstanceHeader(headers, HeaderAuthenticationResults, instance); ok {
material.WriteString(canonicalizeHeaderRelaxed(HeaderAuthenticationResults, aar))
}
if ams, ok := findInstanceHeader(headers, HeaderMessageSignature, instance); ok {
material.WriteString(canonicalizeHeaderRelaxed(HeaderMessageSignature, ams))
}
material.WriteString(canonicalizeHeaderRelaxed(HeaderSeal, stripSignature(value)))
return verifySignature(parseTags(value), []byte(material.String()), publicKeyFor)
}
// stripSignature blanks the "b=" tag, which is how the signature over a header
// that contains itself is defined (RFC 6376 section 3.7).
func stripSignature(value string) string {
index := strings.LastIndex(value, "b=")
if index < 0 {
return value
}
return value[:index+2]
}
func verifySignature(tags map[string]string, material []byte, publicKeyFor func(string, string) (crypto.PublicKey, error)) error {
signature, err := base64.StdEncoding.DecodeString(tags["b"])
if err != nil {
return fmt.Errorf("arc: signature is not valid base64: %w", err)
}
key, err := publicKeyFor(tags["d"], tags["s"])
if err != nil {
return fmt.Errorf("arc: cannot resolve key for %s/%s: %w", tags["d"], tags["s"], err)
}
switch typed := key.(type) {
case ed25519.PublicKey:
if !ed25519.Verify(typed, material, signature) {
return fmt.Errorf("arc: ed25519 signature does not verify for %s", tags["d"])
}
return nil
case *rsa.PublicKey:
digest := sha256.Sum256(material)
if err := rsa.VerifyPKCS1v15(typed, crypto.SHA256, digest[:], signature); err != nil {
return fmt.Errorf("arc: rsa signature does not verify for %s: %w", tags["d"], err)
}
return nil
default:
return fmt.Errorf("arc: unsupported key type %T", key)
}
}
@@ -0,0 +1,221 @@
// Package mailauth runs the inbound authentication chain of PLAN.md section
// 6.1: SPF over the envelope, DKIM over the message, then DMARC alignment on
// top of both.
package mailauth
import (
"bytes"
"context"
"fmt"
"net"
"net/mail"
"strings"
spflib "blitiri.com.ar/go/spf"
dkimlib "github.com/emersion/go-msgauth/dkim"
dmarclib "github.com/emersion/go-msgauth/dmarc"
"lambdamail/protocols/internal/application/port"
)
// LookupTXTFunc resolves TXT records. It is injected so the chain can be
// tested against a fixed zone instead of the live internet.
type LookupTXTFunc func(domain string) ([]string, error)
// Authenticator implements port.MailAuthenticator.
type Authenticator struct {
// authServID is the name this server puts in the Authentication-Results
// header so that downstream consumers can tell our verdicts from a
// forwarder's (RFC 8601 section 2.4).
authServID string
lookupTXT LookupTXTFunc
resolver spflib.DNSResolver
}
func NewAuthenticator(authServID string) *Authenticator {
if authServID == "" {
authServID = "lambdamail"
}
return &Authenticator{authServID: authServID, lookupTXT: net.LookupTXT, resolver: net.DefaultResolver}
}
// WithLookupTXT overrides DNS resolution, for tests and for pointing the
// checks at a validating resolver.
func (a *Authenticator) WithLookupTXT(lookup LookupTXTFunc, resolver spflib.DNSResolver) *Authenticator {
a.lookupTXT = lookup
a.resolver = resolver
return a
}
func (a *Authenticator) Authenticate(ctx context.Context, input port.InboundAuthInput) port.InboundAuthResult {
result := port.InboundAuthResult{
SPF: port.AuthResultNone,
DKIM: port.AuthResultNone,
DMARC: port.AuthResultNone,
}
spfResult, spfDomain := a.checkSPF(ctx, input)
result.SPF = spfResult
dkimResult, dkimDomains := a.checkDKIM(input.Message)
result.DKIM = dkimResult
fromDomain := headerFromDomain(input.Message)
result.DMARC, result.DmarcPolicy = a.checkDMARC(fromDomain, spfResult, spfDomain, dkimResult, dkimDomains)
result.AuthenticationResults = a.formatAuthResults(result, spfDomain, dkimDomains, fromDomain)
return result
}
func (a *Authenticator) checkSPF(ctx context.Context, input port.InboundAuthInput) (result string, domain string) {
if input.ClientIP == nil {
return port.AuthResultNone, ""
}
// RFC 7208 section 2.4: with an empty envelope sender (a bounce) the
// identity checked is the HELO name.
sender := input.EnvelopeFrom
if sender == "" {
sender = "postmaster@" + input.HeloDomain
}
domain = domainOf(sender)
if domain == "" {
return port.AuthResultNone, ""
}
options := []spflib.Option{spflib.WithContext(ctx)}
if a.resolver != nil {
options = append(options, spflib.WithResolver(a.resolver))
}
res, _ := spflib.CheckHostWithSender(input.ClientIP, input.HeloDomain, sender, options...)
return string(res), domain
}
func (a *Authenticator) checkDKIM(message []byte) (result string, signingDomains []string) {
verifications, err := dkimlib.VerifyWithOptions(bytes.NewReader(message), &dkimlib.VerifyOptions{
LookupTXT: a.lookupTXT,
// A message carrying hundreds of signatures is a resource-exhaustion
// attempt, not a legitimate mail (PLAN.md section 12.6).
MaxVerifications: 10,
})
if err != nil && len(verifications) == 0 {
return port.AuthResultPermError, nil
}
if len(verifications) == 0 {
return port.AuthResultNone, nil
}
// One passing signature is enough to authenticate the message; the
// domains of the passing ones are what DMARC aligns against.
anyPass := false
for _, v := range verifications {
if v.Err == nil {
anyPass = true
signingDomains = append(signingDomains, v.Domain)
}
}
if anyPass {
return port.AuthResultPass, signingDomains
}
return port.AuthResultFail, nil
}
func (a *Authenticator) checkDMARC(fromDomain, spfResult, spfDomain, dkimResult string, dkimDomains []string) (result string, policy string) {
if fromDomain == "" {
return port.AuthResultNone, ""
}
record, err := dmarclib.LookupWithOptions(fromDomain, &dmarclib.LookupOptions{LookupTXT: a.lookupTXT})
if err != nil {
if dmarclib.IsTempFail(err) {
return port.AuthResultTempError, ""
}
return port.AuthResultNone, ""
}
policy = string(record.Policy)
// RFC 7489 section 6.6.2: DMARC passes when SPF or DKIM passes *and* the
// authenticated identifier aligns with the From: domain.
spfAligned := spfResult == port.AuthResultPass &&
aligned(fromDomain, spfDomain, record.SPFAlignment == dmarclib.AlignmentStrict)
dkimAligned := false
if dkimResult == port.AuthResultPass {
for _, d := range dkimDomains {
if aligned(fromDomain, d, record.DKIMAlignment == dmarclib.AlignmentStrict) {
dkimAligned = true
break
}
}
}
if spfAligned || dkimAligned {
return port.AuthResultPass, policy
}
return port.AuthResultFail, policy
}
// aligned implements the identifier alignment of RFC 7489 section 3.1: strict
// requires an exact match, relaxed accepts a shared organizational domain.
func aligned(fromDomain, authDomain string, strict bool) bool {
if authDomain == "" {
return false
}
fromDomain, authDomain = strings.ToLower(fromDomain), strings.ToLower(authDomain)
if fromDomain == authDomain {
return true
}
if strict {
return false
}
return strings.HasSuffix(fromDomain, "."+authDomain) || strings.HasSuffix(authDomain, "."+fromDomain)
}
// formatAuthResults renders RFC 8601 section 2.2. The header is written by us
// and describes our own checks, so it must always name this server.
func (a *Authenticator) formatAuthResults(result port.InboundAuthResult, spfDomain string, dkimDomains []string, fromDomain string) string {
var sb strings.Builder
sb.WriteString(a.authServID)
fmt.Fprintf(&sb, ";\r\n\tspf=%s", result.SPF)
if spfDomain != "" {
fmt.Fprintf(&sb, " smtp.mailfrom=%s", spfDomain)
}
fmt.Fprintf(&sb, ";\r\n\tdkim=%s", result.DKIM)
if len(dkimDomains) > 0 {
fmt.Fprintf(&sb, " header.d=%s", dkimDomains[0])
}
fmt.Fprintf(&sb, ";\r\n\tdmarc=%s", result.DMARC)
if fromDomain != "" {
fmt.Fprintf(&sb, " header.from=%s", fromDomain)
}
return sb.String()
}
// headerFromDomain extracts the domain of the From: header, which is the
// identity DMARC is defined over (RFC 7489 section 6.6.1).
func headerFromDomain(message []byte) string {
msg, err := mail.ReadMessage(bytes.NewReader(message))
if err != nil {
return ""
}
addresses, err := mail.ParseAddressList(msg.Header.Get("From"))
if err != nil || len(addresses) == 0 {
return ""
}
return domainOf(addresses[0].Address)
}
func domainOf(address string) string {
if idx := strings.LastIndex(address, "@"); idx >= 0 && idx < len(address)-1 {
return strings.ToLower(address[idx+1:])
}
return ""
}
// Ensure port interface compliance
var _ port.MailAuthenticator = (*Authenticator)(nil)
@@ -0,0 +1,243 @@
package mailauth
import (
"bytes"
"context"
"net"
"strings"
"testing"
dkimlib "github.com/emersion/go-msgauth/dkim"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/infrastructure/dkim"
)
// fakeZone answers TXT and address lookups from a fixed map, so the whole
// authentication chain runs without touching the real internet.
type fakeZone struct {
txt map[string][]string
a map[string][]string
}
func (z *fakeZone) LookupTXT(_ context.Context, name string) ([]string, error) {
return z.lookupTXT(name)
}
func (z *fakeZone) lookupTXT(name string) ([]string, error) {
name = strings.TrimSuffix(name, ".")
if records, ok := z.txt[name]; ok {
return records, nil
}
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
}
func (z *fakeZone) LookupIPAddr(_ context.Context, name string) ([]net.IPAddr, error) {
name = strings.TrimSuffix(name, ".")
var out []net.IPAddr
for _, ip := range z.a[name] {
out = append(out, net.IPAddr{IP: net.ParseIP(ip)})
}
if len(out) == 0 {
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
}
return out, nil
}
func (z *fakeZone) LookupMX(_ context.Context, name string) ([]*net.MX, error) {
return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
}
func (z *fakeZone) LookupAddr(_ context.Context, addr string) ([]string, error) {
return nil, &net.DNSError{Err: "no such host", Name: addr, IsNotFound: true}
}
func newTestAuthenticator(zone *fakeZone) *Authenticator {
return NewAuthenticator("mail.lambdamail.test").WithLookupTXT(zone.lookupTXT, zone)
}
const plainMessage = "From: sender@sender.test\r\n" +
"To: rcpt@lambdamail.test\r\n" +
"Subject: hello\r\n" +
"Date: Mon, 20 Jul 2026 10:00:00 +0000\r\n" +
"Message-ID: <m1@sender.test>\r\n" +
"\r\n" +
"body\r\n"
// A sender whose SPF authorises the connecting IP, with a DMARC record in
// place, must come out as pass/pass on the aligned identifier.
func TestAuthenticate_SpfPassAlignsDmarc(t *testing.T) {
zone := &fakeZone{
txt: map[string][]string{
"sender.test": {"v=spf1 ip4:192.0.2.10 -all"},
"_dmarc.sender.test": {"v=DMARC1; p=reject"},
},
}
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
ClientIP: net.ParseIP("192.0.2.10"),
HeloDomain: "mx.sender.test",
EnvelopeFrom: "sender@sender.test",
Message: []byte(plainMessage),
})
if got.SPF != port.AuthResultPass {
t.Errorf("SPF = %q, want pass", got.SPF)
}
if got.DMARC != port.AuthResultPass {
t.Errorf("DMARC = %q, want pass (SPF aligned with the From: domain)", got.DMARC)
}
if got.DmarcPolicy != "reject" {
t.Errorf("DmarcPolicy = %q, want reject", got.DmarcPolicy)
}
if !strings.Contains(got.AuthenticationResults, "spf=pass") ||
!strings.Contains(got.AuthenticationResults, "dmarc=pass") {
t.Errorf("Authentication-Results does not report the verdicts: %s", got.AuthenticationResults)
}
if !strings.HasPrefix(got.AuthenticationResults, "mail.lambdamail.test") {
t.Errorf("Authentication-Results must name this server first: %s", got.AuthenticationResults)
}
}
// An unauthorised IP must fail SPF, and with no DKIM signature to fall back
// on, DMARC fails too - which is what lets the reject policy be honoured.
func TestAuthenticate_SpfFailWithoutDkimFailsDmarc(t *testing.T) {
zone := &fakeZone{
txt: map[string][]string{
"sender.test": {"v=spf1 ip4:192.0.2.10 -all"},
"_dmarc.sender.test": {"v=DMARC1; p=quarantine"},
},
}
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
ClientIP: net.ParseIP("198.51.100.66"),
HeloDomain: "evil.test",
EnvelopeFrom: "sender@sender.test",
Message: []byte(plainMessage),
})
if got.SPF != port.AuthResultFail {
t.Errorf("SPF = %q, want fail", got.SPF)
}
if got.DMARC != port.AuthResultFail {
t.Errorf("DMARC = %q, want fail", got.DMARC)
}
if got.DmarcPolicy != "quarantine" {
t.Errorf("DmarcPolicy = %q, want quarantine", got.DmarcPolicy)
}
}
// A valid DKIM signature must rescue DMARC even when SPF fails, which is
// exactly the forwarding case DKIM exists for.
func TestAuthenticate_DkimPassAlignsDmarcDespiteSpfFail(t *testing.T) {
generated, err := dkim.Generate(dkim.AlgorithmRSA2048)
if err != nil {
t.Fatalf("Generate: %v", err)
}
signer, err := dkim.ParsePrivateKey(generated.PrivateKeyPEM)
if err != nil {
t.Fatalf("ParsePrivateKey: %v", err)
}
var signed bytes.Buffer
err = dkimlib.Sign(&signed, strings.NewReader(plainMessage), &dkimlib.SignOptions{
Domain: "sender.test",
Selector: "default",
Signer: signer,
HeaderCanonicalization: dkimlib.CanonicalizationRelaxed,
BodyCanonicalization: dkimlib.CanonicalizationRelaxed,
})
if err != nil {
t.Fatalf("Sign: %v", err)
}
zone := &fakeZone{
txt: map[string][]string{
"sender.test": {"v=spf1 ip4:192.0.2.10 -all"},
"_dmarc.sender.test": {"v=DMARC1; p=reject"},
"default._domainkey.sender.test": {"v=DKIM1; k=rsa; p=" + generated.PublicKeyBase64},
},
}
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
ClientIP: net.ParseIP("203.0.113.99"), // forwarder, not in SPF
HeloDomain: "forwarder.test",
EnvelopeFrom: "sender@sender.test",
Message: signed.Bytes(),
})
if got.DKIM != port.AuthResultPass {
t.Fatalf("DKIM = %q, want pass", got.DKIM)
}
if got.SPF == port.AuthResultPass {
t.Fatalf("SPF unexpectedly passed; the test cannot prove DKIM rescued DMARC")
}
if got.DMARC != port.AuthResultPass {
t.Errorf("DMARC = %q, want pass via the aligned DKIM signature", got.DMARC)
}
}
// A tampered body must invalidate the signature.
func TestAuthenticate_TamperedBodyFailsDkim(t *testing.T) {
generated, _ := dkim.Generate(dkim.AlgorithmRSA2048)
signer, _ := dkim.ParsePrivateKey(generated.PrivateKeyPEM)
var signed bytes.Buffer
dkimlib.Sign(&signed, strings.NewReader(plainMessage), &dkimlib.SignOptions{
Domain: "sender.test", Selector: "default", Signer: signer,
HeaderCanonicalization: dkimlib.CanonicalizationRelaxed,
BodyCanonicalization: dkimlib.CanonicalizationRelaxed,
})
tampered := bytes.Replace(signed.Bytes(), []byte("body"), []byte("evil"), 1)
zone := &fakeZone{txt: map[string][]string{
"default._domainkey.sender.test": {"v=DKIM1; k=rsa; p=" + generated.PublicKeyBase64},
}}
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
ClientIP: net.ParseIP("203.0.113.99"),
Message: tampered,
})
if got.DKIM != port.AuthResultFail {
t.Errorf("DKIM = %q, want fail for a tampered body", got.DKIM)
}
}
// A domain publishing no DMARC record yields "none", never a hard failure.
func TestAuthenticate_NoDmarcRecordYieldsNone(t *testing.T) {
zone := &fakeZone{txt: map[string][]string{"sender.test": {"v=spf1 -all"}}}
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
ClientIP: net.ParseIP("198.51.100.1"),
EnvelopeFrom: "sender@sender.test",
Message: []byte(plainMessage),
})
if got.DMARC != port.AuthResultNone {
t.Errorf("DMARC = %q, want none", got.DMARC)
}
if got.DmarcPolicy != "" {
t.Errorf("DmarcPolicy = %q, want empty", got.DmarcPolicy)
}
}
// A bounce arrives with an empty envelope sender; RFC 7208 section 2.4 says
// the HELO identity is checked instead, and it must not crash.
func TestAuthenticate_EmptyEnvelopeFromChecksHelo(t *testing.T) {
zone := &fakeZone{txt: map[string][]string{
"mx.sender.test": {"v=spf1 ip4:192.0.2.10 -all"},
}}
got := newTestAuthenticator(zone).Authenticate(context.Background(), port.InboundAuthInput{
ClientIP: net.ParseIP("192.0.2.10"),
HeloDomain: "mx.sender.test",
EnvelopeFrom: "",
Message: []byte(plainMessage),
})
if got.SPF != port.AuthResultPass {
t.Errorf("SPF = %q, want pass against the HELO identity", got.SPF)
}
}
@@ -0,0 +1,106 @@
package netdns
import (
"context"
"fmt"
"strings"
"time"
"github.com/miekg/dns"
"lambdamail/protocols/internal/domain/entity"
)
// DefaultPublicResolvers are the two independent resolvers PLAN.md section 7.5
// names. Two operators are used deliberately: agreement between them is much
// stronger evidence of real propagation than one authoritative answer.
var DefaultPublicResolvers = []string{"1.1.1.1:53", "8.8.8.8:53"}
// PublicVerifier checks published records through public resolvers.
type PublicVerifier struct {
resolvers []string
client *dns.Client
}
func NewPublicVerifier(resolvers ...string) *PublicVerifier {
if len(resolvers) == 0 {
resolvers = DefaultPublicResolvers
}
normalized := make([]string, 0, len(resolvers))
for _, r := range resolvers {
if !strings.Contains(r, ":") {
r += ":53"
}
normalized = append(normalized, r)
}
return &PublicVerifier{
resolvers: normalized,
client: &dns.Client{Timeout: 5 * time.Second},
}
}
func (v *PublicVerifier) VerifyRecord(ctx context.Context, record entity.DnsRecord) (bool, string) {
qtype, ok := dns.StringToType[strings.ToUpper(record.Type)]
if !ok {
return false, fmt.Sprintf("unsupported record type %q", record.Type)
}
// A proxied record resolves to the provider's anycast addresses rather
// than to the value we asked for, so comparing values would always
// disagree. Presence is all that can be checked.
checkValue := !record.Proxied
var lastDetail string
for _, resolver := range v.resolvers {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(record.Name), qtype)
reply, _, err := v.client.ExchangeContext(ctx, msg, resolver)
if err != nil {
lastDetail = fmt.Sprintf("%s: %v", resolver, err)
continue
}
if reply.Rcode != dns.RcodeSuccess || len(reply.Answer) == 0 {
lastDetail = fmt.Sprintf("%s: no answer for %s %s", resolver, record.Type, record.Name)
continue
}
if !checkValue {
return true, ""
}
if answerMatches(reply.Answer, record.Value) {
return true, ""
}
lastDetail = fmt.Sprintf("%s: published value does not match the desired one", resolver)
}
return false, lastDetail
}
// answerMatches compares the resolver's answer against the desired value.
// Comparison is on the record's own presentation form with the owner name and
// TTL stripped, which is what makes it work uniformly across record types.
func answerMatches(answers []dns.RR, want string) bool {
want = normalizeRdata(want)
for _, rr := range answers {
// String() renders "<name>\t<ttl>\t<class>\t<type>\t<rdata>"; the
// rdata is everything after the fourth tab.
parts := strings.SplitN(rr.String(), "\t", 5)
if len(parts) < 5 {
continue
}
if normalizeRdata(parts[4]) == want {
return true
}
}
return false
}
// normalizeRdata removes the differences that do not change meaning: quoting
// of TXT strings, trailing dots on names, case, and repeated whitespace.
func normalizeRdata(value string) string {
value = strings.ReplaceAll(value, `"`, "")
value = strings.Join(strings.Fields(value), " ")
value = strings.TrimSuffix(value, ".")
return strings.ToLower(value)
}
@@ -0,0 +1,77 @@
package netdns
import (
"context"
"fmt"
"strings"
"time"
"github.com/miekg/dns"
"lambdamail/protocols/internal/domain/valueobject"
)
// TlsaResolver looks up DANE associations through a validating resolver. It
// reports whether the answer carried the AD bit, because a TLSA record that
// was not DNSSEC-validated is worthless: an attacker able to spoof DNS could
// otherwise choose the policy we apply (PLAN.md section 5.1).
type TlsaResolver struct {
// serverAddr is the validating resolver to ask, as host:port.
serverAddr string
client *dns.Client
}
func NewTlsaResolver(serverAddr string) *TlsaResolver {
if serverAddr == "" {
serverAddr = "1.1.1.1:53"
}
if !strings.Contains(serverAddr, ":") {
serverAddr += ":53"
}
return &TlsaResolver{
serverAddr: serverAddr,
client: &dns.Client{Timeout: 10 * time.Second},
}
}
func (r *TlsaResolver) LookupTLSA(ctx context.Context, host string, port int) ([]valueobject.TLSARecord, bool, error) {
name := dns.Fqdn(fmt.Sprintf("_%d._tcp.%s", port, strings.TrimSuffix(host, ".")))
msg := new(dns.Msg)
msg.SetQuestion(name, dns.TypeTLSA)
// Ask the resolver to validate and to report the result in the AD bit.
msg.SetEdns0(4096, true)
msg.AuthenticatedData = true
reply, _, err := r.client.ExchangeContext(ctx, msg, r.serverAddr)
if err != nil {
return nil, false, fmt.Errorf("query TLSA for %s: %w", name, err)
}
switch reply.Rcode {
case dns.RcodeSuccess:
// Fall through to parsing.
case dns.RcodeNameError:
// No TLSA record: the destination is simply not DANE-protected.
return nil, reply.AuthenticatedData, nil
default:
return nil, false, fmt.Errorf("TLSA query for %s returned %s", name, dns.RcodeToString[reply.Rcode])
}
var records []valueobject.TLSARecord
for _, answer := range reply.Answer {
tlsa, ok := answer.(*dns.TLSA)
if !ok {
continue
}
// Usage 2 is rejected by explicit decision (PLAN.md section 5.1), so
// unsupported associations are skipped rather than failing the lookup.
record, err := valueobject.NewTLSARecord(tlsa.Usage, tlsa.Selector, tlsa.MatchingType, strings.ToLower(tlsa.Certificate))
if err != nil {
continue
}
records = append(records, record)
}
return records, reply.AuthenticatedData, nil
}
@@ -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)
}
}