#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,186 @@
package usecase
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"strings"
"time"
"github.com/alexedwards/argon2id"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
)
var (
// ErrSubmissionAuthFailed is returned for both an unknown mailbox and a
// wrong password: telling the two apart would turn submission into an
// account-enumeration oracle.
ErrSubmissionAuthFailed = errors.New("process outbound email: authentication failed")
// ErrSendLimitExceeded is returned when a mailbox has queued more
// recipients in the last hour than its policy allows.
ErrSendLimitExceeded = errors.New("process outbound email: sender hourly recipient limit exceeded")
// ErrSenderNotOwned is returned when the authenticated mailbox tries to
// send as somebody else.
ErrSenderNotOwned = errors.New("process outbound email: envelope sender does not belong to the authenticated mailbox")
)
// deliveryWindow is how long a job may keep retrying before it bounces
// (PLAN.md section 6.3).
const deliveryWindow = 5 * 24 * time.Hour
// sendLimitWindow is the period the per-mailbox recipient limit applies over.
const sendLimitWindow = time.Hour
// ProcessOutboundEmailInput is one authenticated submission.
type ProcessOutboundEmailInput struct {
MailboxID uuid.UUID
SenderAddr string
DomainName string
Recipients []string
Body io.Reader
// MaxRecipientsPerHour is the sender's policy, carried from the
// authenticated session.
MaxRecipientsPerHour int
}
// ProcessOutboundEmailUseCase implements the submission half of PLAN.md
// section 3: authenticate, apply send limits, sign with DKIM, enqueue.
type ProcessOutboundEmailUseCase struct {
auth port.AuthRepository
outbound port.OutboundRepository
blobs port.BlobStorage
signer port.DkimSigner
}
func NewProcessOutboundEmailUseCase(
auth port.AuthRepository,
outbound port.OutboundRepository,
blobs port.BlobStorage,
signer port.DkimSigner,
) *ProcessOutboundEmailUseCase {
return &ProcessOutboundEmailUseCase{auth: auth, outbound: outbound, blobs: blobs, signer: signer}
}
// Authenticate verifies a submission credential and returns the mailbox the
// session may then send as.
func (uc *ProcessOutboundEmailUseCase) Authenticate(ctx context.Context, address, password string) (*port.MailboxAuth, error) {
rec, err := uc.auth.FindByAddress(ctx, address)
if err != nil {
return nil, err
}
if rec == nil {
// Still spend the time a real comparison would, so that a missing
// mailbox is not distinguishable by response time.
_, _ = argon2id.ComparePasswordAndHash(password, decoyHash)
return nil, ErrSubmissionAuthFailed
}
match, err := argon2id.ComparePasswordAndHash(password, rec.PasswordHash)
if err != nil || !match {
return nil, ErrSubmissionAuthFailed
}
return rec, nil
}
// decoyHash is a well-formed Argon2id hash of a value nobody knows. It exists
// only to keep the timing of a failed lookup close to that of a real check.
const decoyHash = "$argon2id$v=19$m=65536,t=3,p=2$YWJjZGVmZ2hpamtsbW5vcA$Zm9yY2VkLWRlY295LWNvbXBhcmlzb24taGFzaA"
// Submit signs the message and queues one delivery job per recipient. The
// message is stored once and every job references the same blob, matching the
// deduplication design of PLAN.md section 9.
func (uc *ProcessOutboundEmailUseCase) Submit(ctx context.Context, input ProcessOutboundEmailInput) error {
if len(input.Recipients) == 0 {
return fmt.Errorf("process outbound email: no recipients")
}
if err := uc.checkSendLimit(ctx, input); err != nil {
return err
}
payload, err := io.ReadAll(input.Body)
if err != nil {
return fmt.Errorf("read submitted message: %w", err)
}
if uc.signer != nil {
signed, err := uc.signer.Sign(ctx, input.DomainName, payload)
if err != nil {
return fmt.Errorf("sign submitted message: %w", err)
}
payload = signed
}
blob, err := uc.blobs.Store(ctx, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("store submitted message: %w", err)
}
now := time.Now()
mailboxID := input.MailboxID
for _, recipient := range input.Recipients {
job := &entity.OutboundJob{
MailboxID: &mailboxID,
BlobID: blob.ID,
EnvelopeFrom: input.SenderAddr,
EnvelopeTo: recipient,
DestinationDomain: recipientDomain(recipient),
Status: entity.OutboundJobStatusQueued,
Attempt: 0,
NextAttemptAt: now,
ExpiresAt: now.Add(deliveryWindow),
}
if err := uc.outbound.Enqueue(ctx, job); err != nil {
return fmt.Errorf("enqueue delivery to %s: %w", recipient, err)
}
}
return nil
}
// checkSendLimit refuses the whole submission when accepting it would take the
// mailbox past its hourly recipient budget. Refusing up front is what contains
// a compromised account; accepting and dropping later would not.
func (uc *ProcessOutboundEmailUseCase) checkSendLimit(ctx context.Context, input ProcessOutboundEmailInput) error {
if input.MaxRecipientsPerHour <= 0 {
return nil
}
alreadySent, err := uc.outbound.CountRecipientsSince(ctx, input.MailboxID, time.Now().Add(-sendLimitWindow))
if err != nil {
return fmt.Errorf("check send limit: %w", err)
}
if alreadySent+len(input.Recipients) > input.MaxRecipientsPerHour {
return fmt.Errorf("%w: %d already sent plus %d requested exceeds %d",
ErrSendLimitExceeded, alreadySent, len(input.Recipients), input.MaxRecipientsPerHour)
}
return nil
}
// AuthorizeSender enforces that a session sends only as itself. Without this
// any account could forge any other address on the server.
func AuthorizeSender(session *port.MailboxAuth, envelopeFrom string) error {
if session == nil {
return ErrSubmissionAuthFailed
}
if !strings.EqualFold(strings.TrimSpace(envelopeFrom), session.EmailAddress) {
return ErrSenderNotOwned
}
return nil
}
func recipientDomain(address string) string {
if idx := strings.LastIndex(address, "@"); idx >= 0 && idx < len(address)-1 {
return strings.ToLower(address[idx+1:])
}
return ""
}
@@ -0,0 +1,171 @@
package usecase
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
"github.com/alexedwards/argon2id"
"github.com/google/uuid"
"lambdamail/protocols/internal/application/port"
"lambdamail/protocols/internal/domain/entity"
)
type fakeAuthRepo struct {
record *port.MailboxAuth
}
func (f *fakeAuthRepo) FindByAddress(_ context.Context, _ string) (*port.MailboxAuth, error) {
return f.record, nil
}
type countingOutboundRepo struct {
fakeOutboundRepo
recentCount int
}
func (c *countingOutboundRepo) CountRecipientsSince(_ context.Context, _ uuid.UUID, _ time.Time) (int, error) {
return c.recentCount, nil
}
type recordingSigner struct {
calledForDomain string
}
func (r *recordingSigner) Sign(_ context.Context, domainName string, message []byte) ([]byte, error) {
r.calledForDomain = domainName
return append([]byte("DKIM-Signature: v=1; d="+domainName+"\r\n"), message...), nil
}
func submissionInput(mailboxID uuid.UUID, recipients ...string) ProcessOutboundEmailInput {
return ProcessOutboundEmailInput{
MailboxID: mailboxID,
SenderAddr: "user@example.test",
DomainName: "example.test",
Recipients: recipients,
Body: bytes.NewBufferString("Subject: hi\r\n\r\nbody"),
MaxRecipientsPerHour: 200,
}
}
// PLAN.md section 3: submission signs with DKIM and enqueues one job per
// recipient, all referencing a single stored copy of the message.
func TestSubmit_SignsAndEnqueuesOneJobPerRecipient(t *testing.T) {
repo := &countingOutboundRepo{}
blobs := &capturingBlobStorage{}
signer := &recordingSigner{}
mailboxID := uuid.New()
uc := NewProcessOutboundEmailUseCase(nil, repo, blobs, signer)
err := uc.Submit(context.Background(), submissionInput(mailboxID, "a@remote.test", "b@other.test"))
if err != nil {
t.Fatalf("Submit: %v", err)
}
if len(repo.jobs) != 2 {
t.Fatalf("enqueued %d jobs, want 2", len(repo.jobs))
}
if signer.calledForDomain != "example.test" {
t.Errorf("signed for domain %q, want example.test", signer.calledForDomain)
}
if !strings.HasPrefix(string(blobs.stored), "DKIM-Signature:") {
t.Errorf("the stored message is not the signed one:\n%s", blobs.stored)
}
first, second := repo.jobs[0], repo.jobs[1]
if first.BlobID != second.BlobID {
t.Error("recipients got separate copies of the message instead of sharing one blob")
}
if first.DestinationDomain != "remote.test" || second.DestinationDomain != "other.test" {
t.Errorf("destination domains = %q, %q", first.DestinationDomain, second.DestinationDomain)
}
if first.Status != entity.OutboundJobStatusQueued {
t.Errorf("status = %s, want QUEUED", first.Status)
}
if first.ExpiresAt.Sub(first.NextAttemptAt) < 4*24*time.Hour {
t.Errorf("delivery window = %v, want the 5 day window of PLAN section 6.3", first.ExpiresAt.Sub(first.NextAttemptAt))
}
if first.MailboxID == nil || *first.MailboxID != mailboxID {
t.Error("job is not attributed to the sending mailbox")
}
}
// A compromised account must hit the wall before the mail is queued, not
// after (PLAN.md section 5.2).
func TestSubmit_RefusesWhenHourlyRecipientLimitWouldBeExceeded(t *testing.T) {
repo := &countingOutboundRepo{recentCount: 199}
uc := NewProcessOutboundEmailUseCase(nil, repo, &capturingBlobStorage{}, nil)
err := uc.Submit(context.Background(), submissionInput(uuid.New(), "a@remote.test", "b@remote.test"))
if !errors.Is(err, ErrSendLimitExceeded) {
t.Fatalf("error = %v, want ErrSendLimitExceeded", err)
}
if len(repo.jobs) != 0 {
t.Errorf("enqueued %d jobs despite exceeding the limit", len(repo.jobs))
}
}
func TestSubmit_AllowsSubmissionExactlyAtTheLimit(t *testing.T) {
repo := &countingOutboundRepo{recentCount: 198}
uc := NewProcessOutboundEmailUseCase(nil, repo, &capturingBlobStorage{}, nil)
if err := uc.Submit(context.Background(), submissionInput(uuid.New(), "a@remote.test", "b@remote.test")); err != nil {
t.Fatalf("Submit at exactly the limit: %v", err)
}
if len(repo.jobs) != 2 {
t.Errorf("enqueued %d jobs, want 2", len(repo.jobs))
}
}
func TestAuthenticate_AcceptsCorrectPasswordAndRejectsWrongOne(t *testing.T) {
hash, err := argon2id.CreateHash("correct horse battery staple", argon2id.DefaultParams)
if err != nil {
t.Fatalf("CreateHash: %v", err)
}
repo := &fakeAuthRepo{record: &port.MailboxAuth{
ID: uuid.New(), PasswordHash: hash, EmailAddress: "user@example.test",
}}
uc := NewProcessOutboundEmailUseCase(repo, nil, nil, nil)
ctx := context.Background()
if _, err := uc.Authenticate(ctx, "user@example.test", "correct horse battery staple"); err != nil {
t.Fatalf("Authenticate with the right password: %v", err)
}
if _, err := uc.Authenticate(ctx, "user@example.test", "wrong"); !errors.Is(err, ErrSubmissionAuthFailed) {
t.Errorf("error = %v, want ErrSubmissionAuthFailed", err)
}
}
// An unknown mailbox and a wrong password must be indistinguishable, so the
// endpoint cannot be used to enumerate accounts.
func TestAuthenticate_UnknownMailboxYieldsTheSameError(t *testing.T) {
uc := NewProcessOutboundEmailUseCase(&fakeAuthRepo{record: nil}, nil, nil, nil)
if _, err := uc.Authenticate(context.Background(), "nobody@example.test", "x"); !errors.Is(err, ErrSubmissionAuthFailed) {
t.Fatalf("error = %v, want ErrSubmissionAuthFailed", err)
}
}
// Without this check any authenticated account could forge mail from any
// other address the server hosts.
func TestAuthorizeSender_RejectsForeignEnvelopeSender(t *testing.T) {
session := &port.MailboxAuth{EmailAddress: "user@example.test"}
if err := AuthorizeSender(session, "user@example.test"); err != nil {
t.Errorf("sending as itself was refused: %v", err)
}
if err := AuthorizeSender(session, "USER@EXAMPLE.TEST"); err != nil {
t.Errorf("address comparison must be case insensitive: %v", err)
}
if err := AuthorizeSender(session, "ceo@example.test"); !errors.Is(err, ErrSenderNotOwned) {
t.Errorf("error = %v, want ErrSenderNotOwned", err)
}
if err := AuthorizeSender(nil, "user@example.test"); !errors.Is(err, ErrSubmissionAuthFailed) {
t.Errorf("error = %v, want ErrSubmissionAuthFailed for an unauthenticated session", err)
}
}
@@ -0,0 +1,143 @@
package usecase
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/smtp"
"strconv"
"strings"
"time"
)
const relayDialTimeout = 15 * time.Second
// RelayConfig is the smarthost of PLAN.md section 10.4, used when the host's
// outbound port 25 is blocked. In this mode the relay takes responsibility for
// transport security to the final destination, so DANE and MTA-STS are not
// evaluated here - they would only describe the wrong hop.
type RelayConfig struct {
Host string
Port int
Username string
Password string
// RootCAs trusts an internal relay presenting a certificate from a
// private CA. Left nil, the system trust store is used. There is
// deliberately no option to skip verification: the credentials sent over
// this connection are worth protecting.
RootCAs *x509.CertPool
}
// Configured reports whether delivery should go through the relay.
func (c RelayConfig) Configured() bool {
return c.Host != ""
}
// Address renders the dial target, defaulting to the submission port.
func (c RelayConfig) Address() string {
port := c.Port
if port == 0 {
port = 587
}
return net.JoinHostPort(c.Host, strconv.Itoa(port))
}
// SpfInclude renders the mechanism to add to the domain's SPF record. Without
// it the relay's own IP is not authorised to send for the domain and every
// message it forwards fails SPF (PLAN.md section 10.4).
func (c RelayConfig) SpfInclude() string {
if !c.Configured() {
return ""
}
// A relay host is normally a subdomain of the provider's sending
// infrastructure; including its organisational domain is what the
// provider documents. The host itself is used when it cannot be reduced.
host := strings.TrimSuffix(strings.ToLower(c.Host), ".")
labels := strings.Split(host, ".")
if len(labels) > 2 {
host = strings.Join(labels[len(labels)-2:], ".")
}
return "include:" + host
}
// authFor selects the SASL mechanism to use against the relay. PLAIN is
// preferred, and CRAM-MD5 is deliberately not offered: it requires the server
// to store a reversible secret.
func (c RelayConfig) authFor(serverName string) smtp.Auth {
if c.Username == "" {
return nil
}
return smtp.PlainAuth("", c.Username, c.Password, serverName)
}
// relayTLSConfig requires a valid certificate. Unlike opportunistic delivery
// to an arbitrary MX, the relay is a host we chose and configured, so there is
// no reason to accept an unverified certificate - and the credentials sent
// over this connection make it worth refusing.
func relayTLSConfig(relay RelayConfig) *tls.Config {
return &tls.Config{
ServerName: relay.Host,
MinVersion: tls.VersionTLS12,
RootCAs: relay.RootCAs,
}
}
// deliverViaRelay hands one message to the smarthost.
func (w *OutboundWorkerUseCase) deliverViaRelay(from, to string, payload []byte) error {
relay := w.relay
addr := relay.Address()
conn, err := net.DialTimeout("tcp", addr, relayDialTimeout)
if err != nil {
return fmt.Errorf("dial relay %s: %w", addr, err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, relay.Host)
if err != nil {
return fmt.Errorf("relay handshake: %w", err)
}
defer client.Close()
if err := client.Hello(w.localHost); err != nil {
return fmt.Errorf("relay EHLO failed: %w", err)
}
if ok, _ := client.Extension("STARTTLS"); ok {
if err := client.StartTLS(relayTLSConfig(relay)); err != nil {
return fmt.Errorf("relay STARTTLS failed: %w", err)
}
} else if relay.Username != "" {
// Sending the relay credentials over a cleartext link would leak them
// to anyone on the path, which is worse than deferring the message.
return fmt.Errorf("relay %s does not offer STARTTLS and credentials must not be sent in the clear", addr)
}
if auth := relay.authFor(relay.Host); auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("relay authentication failed: %w", err)
}
}
if err := client.Mail(from); err != nil {
return fmt.Errorf("relay MAIL FROM failed: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("relay RCPT TO failed: %w", err)
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("relay DATA command failed: %w", err)
}
if _, err := writer.Write(payload); err != nil {
writer.Close()
return fmt.Errorf("relay write DATA failed: %w", err)
}
if err := writer.Close(); err != nil {
return fmt.Errorf("relay close DATA failed: %w", err)
}
return client.Quit()
}
@@ -0,0 +1,309 @@
package usecase
import (
"bufio"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"math/big"
"net"
"strings"
"testing"
"time"
"lambdamail/protocols/internal/domain/entity"
)
// fakeRelay serves one SMTP conversation with STARTTLS and AUTH, recording
// what the client sent.
type fakeRelay struct {
addr string
authSeen chan string
mailFrom chan string
offerTLS bool
certificate tls.Certificate
}
func startFakeRelay(t *testing.T, offerTLS bool) *fakeRelay {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { ln.Close() })
relay := &fakeRelay{
addr: ln.Addr().String(),
authSeen: make(chan string, 1),
mailFrom: make(chan string, 1),
offerTLS: offerTLS,
}
if offerTLS {
relay.certificate = relayTestCertificate(t)
}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go relay.serve(conn)
}
}()
return relay
}
func (f *fakeRelay) serve(conn net.Conn) {
defer conn.Close()
conn.SetDeadline(time.Now().Add(10 * time.Second))
r := bufio.NewReader(conn)
conn.Write([]byte("220 relay.test ESMTP\r\n"))
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"):
if f.offerTLS {
conn.Write([]byte("250-relay.test\r\n250-STARTTLS\r\n250 AUTH PLAIN LOGIN\r\n"))
} else {
conn.Write([]byte("250-relay.test\r\n250 AUTH PLAIN LOGIN\r\n"))
}
case cmd == "STARTTLS":
conn.Write([]byte("220 Ready to start TLS\r\n"))
tlsConn := tls.Server(conn, &tls.Config{Certificates: []tls.Certificate{f.certificate}})
if err := tlsConn.Handshake(); err != nil {
return
}
conn = tlsConn
r = bufio.NewReader(conn)
case strings.HasPrefix(cmd, "AUTH PLAIN"):
payload := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line)[len("AUTH PLAIN"):], " "))
if decoded, err := base64.StdEncoding.DecodeString(payload); err == nil {
select {
case f.authSeen <- strings.ReplaceAll(string(decoded), "\x00", "|"):
default:
}
}
conn.Write([]byte("235 2.7.0 Authentication successful\r\n"))
case strings.HasPrefix(cmd, "MAIL FROM"):
select {
case f.mailFrom <- strings.TrimSpace(line):
default:
}
conn.Write([]byte("250 Ok\r\n"))
case cmd == "DATA":
conn.Write([]byte("354 Send it\r\n"))
for {
body, err := r.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(body) == "." {
break
}
}
conn.Write([]byte("250 2.0.0 Queued\r\n"))
case cmd == "QUIT":
conn.Write([]byte("221 Bye\r\n"))
return
default:
conn.Write([]byte("250 Ok\r\n"))
}
}
}
func relayWorker(t *testing.T, relay RelayConfig) (*OutboundWorkerUseCase, *fakeOutboundRepo) {
t.Helper()
repo := &fakeOutboundRepo{}
repo.Enqueue(context.Background(), newTestJob())
worker := NewOutboundWorkerUseCase(
repo,
// A resolver that would fail loudly if it were consulted: with a
// relay configured, MX resolution must not happen at all.
&fakeMXResolver{hosts: []string{"127.0.0.1:1"}},
&fakeBlobReader{payload: []byte("From: a\r\nTo: b\r\n\r\nTest")},
nil, nil, "mail.local",
)
worker.SetRelay(relay)
return worker, repo
}
// PLAN.md section 10.4: with a smarthost every message leaves through the
// relay, whatever the destination domain is.
func TestOutboundWorker_DeliversThroughRelayInsteadOfMX(t *testing.T) {
relay := startFakeRelay(t, true)
host, portStr, _ := net.SplitHostPort(relay.addr)
worker, repo := relayWorker(t, RelayConfig{
Host: host,
Port: atoiOrZero(portStr),
Username: "relay-user",
Password: "relay-pass",
RootCAs: relayTestRoots,
})
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
job := repo.jobs[0]
if job.Status != entity.OutboundJobStatusDelivered {
t.Fatalf("status = %s, want DELIVERED (last error: %s)", job.Status, job.LastError)
}
if job.TlsPolicyUsed != entity.TLSModeRelay {
t.Errorf("TlsPolicyUsed = %q, want relay", job.TlsPolicyUsed)
}
select {
case credentials := <-relay.authSeen:
if !strings.Contains(credentials, "relay-user") || !strings.Contains(credentials, "relay-pass") {
t.Errorf("relay credentials = %q", credentials)
}
case <-time.After(2 * time.Second):
t.Error("the worker never authenticated against the relay")
}
select {
case from := <-relay.mailFrom:
if !strings.Contains(from, "user@domain.test") {
t.Errorf("MAIL FROM = %q, want the original envelope sender", from)
}
case <-time.After(2 * time.Second):
t.Error("the relay never received MAIL FROM")
}
}
// Sending the relay credentials over a cleartext link would leak them; the
// message is deferred instead.
func TestOutboundWorker_RefusesRelayCredentialsWithoutStartTLS(t *testing.T) {
relay := startFakeRelay(t, false)
host, portStr, _ := net.SplitHostPort(relay.addr)
worker, repo := relayWorker(t, RelayConfig{
Host: host, Port: atoiOrZero(portStr),
Username: "relay-user", Password: "relay-pass",
})
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
job := repo.jobs[0]
if job.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED", job.Status)
}
if !strings.Contains(job.LastError, "STARTTLS") {
t.Errorf("LastError = %q, want it to name the missing STARTTLS", job.LastError)
}
}
// An unreachable relay defers rather than bounces: the relay may come back.
func TestOutboundWorker_UnreachableRelayDefers(t *testing.T) {
worker, repo := relayWorker(t, RelayConfig{Host: "127.0.0.1", Port: 1})
if _, err := worker.ProcessBatch(context.Background(), "w1", 10); err != nil {
t.Fatalf("ProcessBatch: %v", err)
}
if job := repo.jobs[0]; job.Status != entity.OutboundJobStatusDeferred {
t.Errorf("status = %s, want DEFERRED", job.Status)
}
}
func TestRelayConfig_AddressDefaultsToSubmissionPort(t *testing.T) {
if got := (RelayConfig{Host: "relay.example.net"}).Address(); got != "relay.example.net:587" {
t.Errorf("Address = %q, want relay.example.net:587", got)
}
if got := (RelayConfig{Host: "relay.example.net", Port: 2525}).Address(); got != "relay.example.net:2525" {
t.Errorf("Address = %q, want relay.example.net:2525", got)
}
}
// PLAN.md section 10.4: the published SPF must authorise the relay, otherwise
// the hard fail rejects everything the relay forwards.
func TestRelayConfig_SpfInclude(t *testing.T) {
cases := map[string]string{
"smtp.sendgrid.net": "include:sendgrid.net",
"relay.example.net": "include:example.net",
"example.net": "include:example.net",
"": "",
}
for host, want := range cases {
if got := (RelayConfig{Host: host}).SpfInclude(); got != want {
t.Errorf("SpfInclude(%q) = %q, want %q", host, got, want)
}
}
}
func atoiOrZero(s string) int {
value := 0
for _, c := range s {
if c < '0' || c > '9' {
return 0
}
value = value*10 + int(c-'0')
}
return value
}
// relayTestCertificate issues a certificate for 127.0.0.1 and registers it as
// a trusted root for the test, so the relay connection is verified for real
// rather than with verification switched off.
func relayTestCertificate(t *testing.T) tls.Certificate {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "relay.test"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"127.0.0.1", "relay.test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
parsed, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
relayTestRoots = x509.NewCertPool()
relayTestRoots.AddCert(parsed)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key}
}
// relayTestRoots holds the CA pool for the certificate issued above.
var relayTestRoots *x509.CertPool
@@ -0,0 +1,246 @@
package usecase
import (
"context"
"crypto"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/pem"
"fmt"
"time"
)
// RolloverStage names the state the rollover is in. It follows the state
// machine of PLAN.md section 5.1, which exists because the naive approach -
// publish the digest of the current certificate and update it at renewal -
// permanently breaks mail delivery to every DANE-validating receiver for as
// long as DNS has not caught up.
type RolloverStage string
const (
// StageStable means one association is published and the certificate
// matches it. Nothing to do.
StageStable RolloverStage = "STABLE"
// StagePublishNext means a key for the next certificate has been
// generated and its association has to go into DNS.
StagePublishNext RolloverStage = "PUBLISH_NEXT"
// StageAwaitPropagation means both associations are published and the
// rollover is waiting out the DNS caches before switching over.
StageAwaitPropagation RolloverStage = "AWAIT_PROPAGATION"
// StageRenew means the wait is over and the certificate can be re-issued
// against the pre-generated key.
StageRenew RolloverStage = "RENEW"
// StageCleanup means the new certificate is live and the old association
// can be retired, again only after the caches have expired.
StageCleanup RolloverStage = "CLEANUP"
)
// RolloverConfig tunes the timings.
type RolloverConfig struct {
// RenewBefore is how long before expiry a renewal starts. Let's Encrypt
// certificates last 90 days and are renewable from day 60.
RenewBefore time.Duration
// PrepareBefore is how long before the renewal the next key is generated
// and published. It has to exceed PropagationWait comfortably.
PrepareBefore time.Duration
// PropagationWait is how long both associations stay published before the
// certificate changes. RFC 7671 section 8.1 asks for at least twice the
// record TTL; a day is used because a resolver that ignores TTLs is not
// hypothetical.
PropagationWait time.Duration
}
// DefaultRolloverConfig follows PLAN.md section 5.1.
func DefaultRolloverConfig() RolloverConfig {
return RolloverConfig{
RenewBefore: 30 * 24 * time.Hour,
PrepareBefore: 30 * 24 * time.Hour,
PropagationWait: 24 * time.Hour,
}
}
// RolloverState is everything the decision needs.
type RolloverState struct {
// CurrentDigest is the TLSA value of the certificate being served.
CurrentDigest string
// NotAfter is the expiry of the certificate being served.
NotAfter time.Time
// PendingDigest is the association of the pre-generated next key, empty
// when no rollover is in flight.
PendingDigest string
// PendingPublishedAt is when that association went into DNS.
PendingPublishedAt *time.Time
// CertificateMatchesPending is true once the served certificate was
// issued against the pending key.
CertificateMatchesPending bool
}
// RolloverDecision is the action to take now.
type RolloverDecision struct {
Stage RolloverStage
// Publish is the full set of associations that must be in DNS after this
// step. It is always the complete desired state, never a delta, so
// applying it is idempotent.
Publish []string
Reason string
}
// RolloverTlsaUseCase decides how to move a DANE rollover forward. It is a
// pure decision function over the observed state: the caller performs the DNS
// writes and the issuance, which keeps the risky ordering rules in one
// testable place.
type RolloverTlsaUseCase struct {
config RolloverConfig
now func() time.Time
}
func NewRolloverTlsaUseCase(config RolloverConfig) *RolloverTlsaUseCase {
if config.RenewBefore == 0 {
config = DefaultRolloverConfig()
}
return &RolloverTlsaUseCase{config: config, now: time.Now}
}
// WithClock fixes the clock for tests.
func (uc *RolloverTlsaUseCase) WithClock(now func() time.Time) *RolloverTlsaUseCase {
uc.now = now
return uc
}
func (uc *RolloverTlsaUseCase) Decide(_ context.Context, state RolloverState) RolloverDecision {
now := uc.now()
// The new certificate is live. Both associations are still published;
// the old one may only go once the caches that hold it have expired.
if state.CertificateMatchesPending {
if state.PendingPublishedAt != nil && now.Sub(*state.PendingPublishedAt) < uc.config.PropagationWait {
return RolloverDecision{
Stage: StageCleanup,
Publish: dedupe(state.CurrentDigest, state.PendingDigest),
Reason: "new certificate is live; keeping the previous association until caches expire",
}
}
return RolloverDecision{
Stage: StageStable,
Publish: dedupe(state.PendingDigest),
Reason: "rollover complete; the previous association can be removed",
}
}
// A rollover already in flight: both are published, so the only question
// is whether the wait is over.
if state.PendingDigest != "" {
if state.PendingPublishedAt == nil {
return RolloverDecision{
Stage: StagePublishNext,
Publish: dedupe(state.CurrentDigest, state.PendingDigest),
Reason: "publishing the association for the pre-generated next key",
}
}
if now.Sub(*state.PendingPublishedAt) < uc.config.PropagationWait {
remaining := uc.config.PropagationWait - now.Sub(*state.PendingPublishedAt)
return RolloverDecision{
Stage: StageAwaitPropagation,
Publish: dedupe(state.CurrentDigest, state.PendingDigest),
Reason: fmt.Sprintf("waiting %s more for the next association to propagate", remaining.Round(time.Minute)),
}
}
return RolloverDecision{
Stage: StageRenew,
Publish: dedupe(state.CurrentDigest, state.PendingDigest),
Reason: "the next association has propagated; the certificate can be issued against the pre-generated key",
}
}
// No rollover in flight. Start one only when the renewal is close enough.
if !state.NotAfter.IsZero() && now.After(state.NotAfter.Add(-uc.config.PrepareBefore)) {
return RolloverDecision{
Stage: StagePublishNext,
Publish: dedupe(state.CurrentDigest),
Reason: "renewal is approaching; a next key has to be generated and published",
}
}
return RolloverDecision{
Stage: StageStable,
Publish: dedupe(state.CurrentDigest),
Reason: "certificate is current and no rollover is due",
}
}
// ShouldRenewNow reports whether the certificate itself is due for renewal,
// independently of DANE. Deployments without DANE use only this.
func (uc *RolloverTlsaUseCase) ShouldRenewNow(notAfter time.Time) bool {
if notAfter.IsZero() {
return true
}
return uc.now().After(notAfter.Add(-uc.config.RenewBefore))
}
// TlsaDigestForKey renders the "3 1 1" association of a private key: the
// SHA-256 of its SubjectPublicKeyInfo (RFC 6698 section 2.1.2). Deriving it
// from the key rather than from a certificate is what lets the association be
// published before the certificate exists.
func TlsaDigestForKey(privateKeyPEM []byte) (string, error) {
block, _ := pem.Decode(privateKeyPEM)
if block == nil {
return "", fmt.Errorf("rollover: private key is not valid PEM")
}
var publicKey any
switch parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes); {
case err == nil:
// crypto.Signer is the interface every usable private key satisfies;
// its Public method returns crypto.PublicKey, which is a defined type
// rather than a bare any, so it has to be named exactly.
signer, ok := parsed.(crypto.Signer)
if !ok {
return "", fmt.Errorf("rollover: key of type %T has no public half", parsed)
}
publicKey = signer.Public()
default:
ecKey, ecErr := x509.ParseECPrivateKey(block.Bytes)
if ecErr != nil {
return "", fmt.Errorf("rollover: parse private key: %w", err)
}
publicKey = &ecKey.PublicKey
}
spki, err := x509.MarshalPKIXPublicKey(publicKey)
if err != nil {
return "", fmt.Errorf("rollover: marshal public key: %w", err)
}
sum := sha256.Sum256(spki)
return hex.EncodeToString(sum[:]), nil
}
// TlsaDigestForCertificate renders the association of an already issued
// certificate, used to confirm what is actually being served.
func TlsaDigestForCertificate(certificatePEM []byte) (string, error) {
block, _ := pem.Decode(certificatePEM)
if block == nil {
return "", fmt.Errorf("rollover: certificate is not valid PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("rollover: parse certificate: %w", err)
}
sum := sha256.Sum256(cert.RawSubjectPublicKeyInfo)
return hex.EncodeToString(sum[:]), nil
}
func dedupe(values ...string) []string {
seen := map[string]bool{}
var out []string
for _, value := range values {
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
return out
}
@@ -0,0 +1,220 @@
package usecase
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"testing"
"time"
)
func rolloverAt(t *testing.T, now time.Time) *RolloverTlsaUseCase {
t.Helper()
return NewRolloverTlsaUseCase(DefaultRolloverConfig()).
WithClock(func() time.Time { return now })
}
func contains(values []string, want string) bool {
for _, v := range values {
if v == want {
return true
}
}
return false
}
var now = time.Date(2026, 7, 20, 12, 0, 0, 0, time.UTC)
// Far from expiry there is nothing to do, and exactly one association stays
// published.
func TestRollover_StableWhenRenewalIsFarAway(t *testing.T) {
decision := rolloverAt(t, now).Decide(context.Background(), RolloverState{
CurrentDigest: "aaaa",
NotAfter: now.Add(80 * 24 * time.Hour),
})
if decision.Stage != StageStable {
t.Errorf("stage = %s, want STABLE", decision.Stage)
}
if len(decision.Publish) != 1 || decision.Publish[0] != "aaaa" {
t.Errorf("Publish = %v, want just the current association", decision.Publish)
}
}
// As renewal approaches, a next key has to be generated and published.
func TestRollover_StartsWhenRenewalApproaches(t *testing.T) {
decision := rolloverAt(t, now).Decide(context.Background(), RolloverState{
CurrentDigest: "aaaa",
NotAfter: now.Add(10 * 24 * time.Hour),
})
if decision.Stage != StagePublishNext {
t.Errorf("stage = %s, want PUBLISH_NEXT", decision.Stage)
}
}
// The critical invariant of PLAN.md section 5.1: while a rollover is in
// flight, BOTH associations stay published. Dropping the current one before
// the new certificate is live is exactly what breaks DANE delivery.
func TestRollover_PublishesBothAssociationsWhileInFlight(t *testing.T) {
published := now.Add(-2 * time.Hour)
decision := rolloverAt(t, now).Decide(context.Background(), RolloverState{
CurrentDigest: "aaaa",
PendingDigest: "bbbb",
PendingPublishedAt: &published,
NotAfter: now.Add(10 * 24 * time.Hour),
})
if decision.Stage != StageAwaitPropagation {
t.Errorf("stage = %s, want AWAIT_PROPAGATION", decision.Stage)
}
if len(decision.Publish) != 2 {
t.Fatalf("Publish = %v, want both associations", decision.Publish)
}
if !contains(decision.Publish, "aaaa") || !contains(decision.Publish, "bbbb") {
t.Errorf("Publish = %v, want the current and the next association", decision.Publish)
}
}
// The certificate must not change before the new association has had time to
// propagate.
func TestRollover_WaitsOutPropagationBeforeRenewing(t *testing.T) {
justPublished := now.Add(-30 * time.Minute)
longPublished := now.Add(-30 * time.Hour)
early := rolloverAt(t, now).Decide(context.Background(), RolloverState{
CurrentDigest: "aaaa", PendingDigest: "bbbb", PendingPublishedAt: &justPublished,
})
if early.Stage != StageAwaitPropagation {
t.Errorf("stage = %s, want AWAIT_PROPAGATION 30 minutes in", early.Stage)
}
ready := rolloverAt(t, now).Decide(context.Background(), RolloverState{
CurrentDigest: "aaaa", PendingDigest: "bbbb", PendingPublishedAt: &longPublished,
})
if ready.Stage != StageRenew {
t.Errorf("stage = %s, want RENEW after the propagation window", ready.Stage)
}
if len(ready.Publish) != 2 {
t.Errorf("Publish = %v, both associations must remain during renewal", ready.Publish)
}
}
// Once the new certificate is live, the old association is retired - but only
// after the caches holding it have expired.
func TestRollover_RetiresOldAssociationOnlyAfterPropagation(t *testing.T) {
recent := now.Add(-2 * time.Hour)
old := now.Add(-40 * time.Hour)
cleanup := rolloverAt(t, now).Decide(context.Background(), RolloverState{
CurrentDigest: "aaaa", PendingDigest: "bbbb",
PendingPublishedAt: &recent, CertificateMatchesPending: true,
})
if cleanup.Stage != StageCleanup {
t.Errorf("stage = %s, want CLEANUP", cleanup.Stage)
}
if len(cleanup.Publish) != 2 {
t.Errorf("Publish = %v, the old association must survive the cache window", cleanup.Publish)
}
done := rolloverAt(t, now).Decide(context.Background(), RolloverState{
CurrentDigest: "aaaa", PendingDigest: "bbbb",
PendingPublishedAt: &old, CertificateMatchesPending: true,
})
if done.Stage != StageStable {
t.Errorf("stage = %s, want STABLE", done.Stage)
}
if len(done.Publish) != 1 || done.Publish[0] != "bbbb" {
t.Errorf("Publish = %v, want only the new association", done.Publish)
}
}
// A pending key whose association has not been published yet must be
// published before anything else happens.
func TestRollover_PublishesUnpublishedPendingAssociation(t *testing.T) {
decision := rolloverAt(t, now).Decide(context.Background(), RolloverState{
CurrentDigest: "aaaa", PendingDigest: "bbbb", PendingPublishedAt: nil,
})
if decision.Stage != StagePublishNext {
t.Errorf("stage = %s, want PUBLISH_NEXT", decision.Stage)
}
if !contains(decision.Publish, "aaaa") || !contains(decision.Publish, "bbbb") {
t.Errorf("Publish = %v, want both", decision.Publish)
}
}
func TestRollover_ShouldRenewNow(t *testing.T) {
uc := rolloverAt(t, now)
if uc.ShouldRenewNow(now.Add(60 * 24 * time.Hour)) {
t.Error("a certificate valid for another 60 days should not be renewed")
}
if !uc.ShouldRenewNow(now.Add(10 * 24 * time.Hour)) {
t.Error("a certificate expiring in 10 days should be renewed")
}
if !uc.ShouldRenewNow(time.Time{}) {
t.Error("a missing certificate should be issued")
}
}
// The digest must be derivable from the key alone. That is the property the
// whole rollover depends on: the association for the next certificate has to
// be publishable before that certificate is issued.
func TestTlsaDigestForKey_MatchesTheIssuedCertificate(t *testing.T) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
if err != nil {
t.Fatalf("marshal key: %v", err)
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
fromKey, err := TlsaDigestForKey(keyPEM)
if err != nil {
t.Fatalf("TlsaDigestForKey: %v", err)
}
if len(fromKey) != 64 {
t.Errorf("digest length = %d, want 64 hex characters for SHA-256", len(fromKey))
}
// Issue a certificate against that same key and confirm both agree.
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "mail.example.test"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(90 * 24 * time.Hour),
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
fromCert, err := TlsaDigestForCertificate(certPEM)
if err != nil {
t.Fatalf("TlsaDigestForCertificate: %v", err)
}
if fromKey != fromCert {
t.Errorf("digest from key = %s, from certificate = %s; they must agree or the pre-published association would never match", fromKey, fromCert)
}
}
func TestTlsaDigest_RejectsGarbage(t *testing.T) {
if _, err := TlsaDigestForKey([]byte("not pem")); err == nil {
t.Error("expected an error for a non-PEM key")
}
if _, err := TlsaDigestForCertificate([]byte("not pem")); err == nil {
t.Error("expected an error for a non-PEM certificate")
}
}