#17: implement DANE TLSA, MTA-STS policy resolution, ARC sealing and mail authentication
This commit is contained in:
1 parent
df8b809683
commit
bd0af98076
22 files changed
+3296
No files matched your search
@@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user