#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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user