diff --git a/.env.example b/.env.example index eb87710..53b7715 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,7 @@ KEY_FILE= ENABLE_SPF="true" ENABLE_DKIM="true" +ENABLE_DKIM2="true" ENABLE_DMARC="true" VERBOSE="true" # Metrics settings diff --git a/.gitattributes b/.gitattributes index dfe0770..7c30a82 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ # Auto detect text files and perform LF normalization * text=auto + +# DKIM2 fixtures are signed over their exact CRLF bytes. +internal/auth/testdata/*.eml -text diff --git a/README.md b/README.md index b9edf35..47ecba5 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ A SMTP server that receives emails from any domain without authentication and fo - **SPF Validation (RFC 7208)**: Validates Sender Policy Framework with HELO and envelope sender - **DKIM Validation (RFC 6376)**: Verifies DomainKeys Identified Mail signatures with multi-signature support +- **DKIM2 Verification (draft-ietf-dkim-dkim2-spec-09)**: Verifies DKIM2 signatures against the SMTP envelope and reports the result next to DKIM. It does not accept or reject mail, and it is not an input to DMARC - **DMARC Validation (RFC 7489)**: Evaluates DMARC policy with hierarchical domain lookup and subdomain policy inheritance - **Comprehensive Results**: Detailed authentication results with domain, mechanism, and alignment data - **Domain Hierarchy Tracking**: Tracks all attempted DMARC lookups with detailed per-domain results @@ -136,6 +137,7 @@ Usage of ./inboundparse: -message-processing-timeout int Per-message processing timeout in seconds, including auth and webhook (default 300) -enable-spf Enable SPF validation (default true) -enable-dkim Enable DKIM validation (default true) + -enable-dkim2 Enable DKIM2 verification (default true) -enable-dmarc Enable DMARC validation (default true) -cert-file string TLS cert file (optional) -key-file string TLS private key (optional) @@ -197,6 +199,7 @@ The image entrypoint requests a Let's Encrypt certificate on startup when DNS cr - `ENABLE_SPF` Enable SPF checking (`true`/`false`, default: `true`) - `ENABLE_DKIM` Enable DKIM checking (`true`/`false`, default: `true`) +- `ENABLE_DKIM2` Enable DKIM2 verification (`true`/`false`, default: `true`) - `ENABLE_DMARC` Enable DMARC checking (`true`/`false`, default: `true`) - `VERBOSE` Enable verbose (debug) logging (`true`/`false`, default: `false`) @@ -219,7 +222,9 @@ When metrics are enabled on a non-loopback address (`0.0.0.0`, public IP, etc.), ## Webhook Payload -The webhook receives a JSON document shaped like `domain.WebhookPayload`: message metadata and `authentication_results` for SPF/DKIM/DMARC. `from` and `to` are `net/mail.Address` values (JSON fields **`Name`** and **`Address`**). `timestamp` is taken from the parsed `Date` header when present. Attachments and inline parts use the structures from [`letters`](https://github.com/mnako/letters). +The webhook receives a JSON document shaped like `domain.WebhookPayload`: message metadata and `authentication_results` for SPF, DKIM, DKIM2, and DMARC. `from` and `to` are `net/mail.Address` values (JSON fields **`Name`** and **`Address`**). `timestamp` is taken from the parsed `Date` header when present. Attachments and inline parts use the structures from [`letters`](https://github.com/mnako/letters). + +`authentication_results.dkim2` is the draft-09 verification state. `result` is `none` when the message has no DKIM2 fields, and otherwise `pass`, `fail`, `temperror`, or `permerror`. `reason` is the library reason code (`none` on pass, `missing_key`, `envelope_mismatch`, and the rest of that closed set). A canceled check is `temperror` with reason `unavailable`. The signing domain is not included. `signatures[].selector` is present only for a signature set that did not pass. DKIM2 is not used when evaluating DMARC. ```json { @@ -263,6 +268,32 @@ The webhook receives a JSON document shaped like `domain.WebhookPayload`: messag "raw": "", "error": "" }, + "dkim2": { + "result": "pass", + "reason": "none", + "scope": "chain", + "historical_content": "complete", + "historical_signatures": "complete", + "custody": "not_present", + "sequence": 1, + "instance": 1, + "checks": [ + { "class": "body_hash", "reason": "none" }, + { "class": "domain_alignment", "reason": "none" }, + { "class": "envelope", "reason": "none" }, + { "class": "header_hash", "reason": "none" }, + { "class": "next_domain", "reason": "none" }, + { "class": "signature", "reason": "none" }, + { "class": "timestamp", "reason": "none" } + ], + "signatures": [ + { + "algorithm": "ed25519-sha256", + "status": "pass", + "reason": "none" + } + ] + }, "dmarc": { "result": "pass", "raw": "v=DMARC1; p=reject; ...", diff --git a/go.mod b/go.mod index e307f67..3040792 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module inboundparse go 1.27.3 require ( + github.com/croessner/dkim2 v0.3.3 github.com/emersion/go-msgauth v0.7.0 github.com/emersion/go-smtp v0.24.0 github.com/failsafe-go/failsafe-go v0.9.6 @@ -15,8 +16,8 @@ require ( github.com/rs/zerolog v1.35.1 github.com/stretchr/testify v1.11.1 github.com/zaccone/spf v0.0.0-20170817004109-76747b8658d9 - golang.org/x/net v0.60.0 - golang.org/x/sync v0.23.0 + golang.org/x/net v0.61.0 + golang.org/x/sync v0.24.0 ) require ( @@ -35,11 +36,11 @@ require ( github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - golang.org/x/crypto v0.57.0 // indirect + golang.org/x/crypto v0.58.0 // indirect golang.org/x/mod v0.41.0 // indirect - golang.org/x/sys v0.48.0 // indirect - golang.org/x/text v0.42.0 // indirect - golang.org/x/tools v0.49.0 // indirect + golang.org/x/sys v0.49.0 // indirect + golang.org/x/text v0.43.0 // indirect + golang.org/x/tools v0.51.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b4a5371..8c1ab0d 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/croessner/dkim2 v0.3.3 h1:ZW/ZNF+t/xNOK7fXVXPywI0RJGcx1VHVbwnr5z82UKk= +github.com/croessner/dkim2 v0.3.3/go.mod h1:YiC5Mrzce8u0KBcZNYmwno2tJsn0/QGiuXqoKgivWc4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc= @@ -73,20 +75,20 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= -golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= +golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= -golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= -golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= -golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= +golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= +golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= +golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= +golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= diff --git a/internal/app/app.go b/internal/app/app.go index a0705f9..7fbd5a5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -8,10 +8,13 @@ import ( "inboundparse/internal/observability" "inboundparse/internal/processor" "inboundparse/internal/smtp" + "net" "os" "os/signal" "syscall" "time" + + "github.com/croessner/dkim2" ) // App represents the application @@ -60,11 +63,31 @@ func NewApp(cfg *config.Config) (*App, error) { metrics = &observability.NoOpMetricsCollector{} } + // One verifier for the process. DNSPublicKeyProvider also supplies the + // draft-09 From-subdomain policy, so NewVerifier keeps that check on. + var dkim2Verifier *dkim2.Verifier + if cfg.EnableDKIM2 { + transport, err := dkim2.NewNetTXTTransport(net.DefaultResolver) + if err != nil { + return nil, fmt.Errorf("failed to create DKIM2 DNS transport: %w", err) + } + provider, err := dkim2.NewDNSPublicKeyProvider(transport) + if err != nil { + return nil, fmt.Errorf("failed to create DKIM2 key provider: %w", err) + } + dkim2Verifier, err = dkim2.NewVerifier(provider) + if err != nil { + return nil, fmt.Errorf("failed to create DKIM2 verifier: %w", err) + } + } + // Initialize auth checker authChecker := auth.NewAuthChecker(auth.AuthConfig{ - EnableSPF: cfg.EnableSPF, - EnableDKIM: cfg.EnableDKIM, - EnableDMARC: cfg.EnableDMARC, + EnableSPF: cfg.EnableSPF, + EnableDKIM: cfg.EnableDKIM, + EnableDKIM2: cfg.EnableDKIM2, + EnableDMARC: cfg.EnableDMARC, + DKIM2Verifier: dkim2Verifier, }) // Initialize webhook sender @@ -95,6 +118,7 @@ func NewApp(cfg *config.Config) (*App, error) { processor.ProcessorConfig{ EnableSPF: cfg.EnableSPF, EnableDKIM: cfg.EnableDKIM, + EnableDKIM2: cfg.EnableDKIM2, EnableDMARC: cfg.EnableDMARC, }, authChecker, @@ -166,6 +190,7 @@ func (a *App) Start() error { Str("smtp_tls_address", a.config.ListenAddrTLS). Bool("spf_enabled", a.config.EnableSPF). Bool("dkim_enabled", a.config.EnableDKIM). + Bool("dkim2_enabled", a.config.EnableDKIM2). Bool("dmarc_enabled", a.config.EnableDMARC). Bool("metrics_enabled", a.config.EnableMetrics). Bool("sentry_enabled", a.config.EnableSentry). diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 5689100..23c3a5f 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -79,6 +79,10 @@ func (m *mockAuthChecker) CheckDKIM(ctx context.Context, rawMessage string, logg return nil, nil } +func (m *mockAuthChecker) CheckDKIM2(ctx context.Context, rawMessage string, from string, to []string, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DKIM2Result, error) { + return nil, nil +} + func (m *mockAuthChecker) CheckDMARC(ctx context.Context, rawMessage string, headers interface{}, spfResult *domain.SPFResult, dkimResult *domain.DKIMResult, logger observability.Logger) (*domain.DMARCResult, error) { return nil, nil } @@ -143,6 +147,23 @@ func TestNewApp(t *testing.T) { } } +func TestNewApp_DKIM2Enabled(t *testing.T) { + config := &config.Config{ + ListenAddr: "127.0.0.1:0", + WebhookURL: "http://localhost:8080/webhook", + ServerName: "test", + EnableDKIM2: true, + } + + app, err := NewApp(config) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if app == nil || app.authChecker == nil { + t.Fatal("Expected app with an auth checker") + } +} + func TestNewApp_InvalidConfig(t *testing.T) { // Test with nil config - this should return an error app, err := NewApp(nil) diff --git a/internal/auth/dkim2.go b/internal/auth/dkim2.go new file mode 100644 index 0000000..a823425 --- /dev/null +++ b/internal/auth/dkim2.go @@ -0,0 +1,140 @@ +package auth + +import ( + "context" + "errors" + "inboundparse/internal/domain" + "inboundparse/internal/observability" + "strings" + "time" + + "github.com/croessner/dkim2" +) + +// dkim2Checker verifies inbound messages with a process-wide Verifier. +// Signing stays out of this path. +type dkim2Checker struct { + verifier *dkim2.Verifier +} + +// NewDKIM2Checker returns a checker that reuses verifier. +func NewDKIM2Checker(verifier *dkim2.Verifier) DKIM2Checker { + return &dkim2Checker{verifier: verifier} +} + +// CheckDKIM2 reports draft-09 verification. A message with no DKIM2 fields is +// none. Protocol failures stay on the result. A canceled context is temperror +// with reason unavailable so the webhook still receives a value. +func (c *dkim2Checker) CheckDKIM2(ctx context.Context, rawMessage string, from string, to []string, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DKIM2Result, error) { + start := time.Now() + defer func() { + metrics.TrackDKIM2Duration(time.Since(start)) + }() + + if c == nil || c.verifier == nil { + metrics.TrackAuthError("dkim2", "verifier_error") + logger.Error().Msg("DKIM2 verifier is not configured") + return &domain.DKIM2Result{Result: "permerror", Reason: "invalid_request"}, nil + } + + reverse := bracketEnvelopePath(from) + forwards := make([][]byte, len(to)) + for i, rcpt := range to { + forwards[i] = []byte(bracketEnvelopePath(rcpt)) + } + + assessment, err := c.verifier.Assess(ctx, dkim2.NewVerifyRequest([]byte(rawMessage), []byte(reverse), forwards)) + if err != nil { + if canceled(ctx, err) { + metrics.TrackAuthError("dkim2", "canceled") + logger.Warn(). + Err(err). + Str("result", "temperror"). + Str("reason", "unavailable"). + Msg("DKIM2 verification canceled") + return &domain.DKIM2Result{Result: "temperror", Reason: "unavailable"}, nil + } + metrics.TrackAuthError("dkim2", "verifier_error") + logger.Warn(). + Err(err). + Str("result", "permerror"). + Str("reason", "invalid_request"). + Msg("DKIM2 verification error") + return &domain.DKIM2Result{Result: "permerror", Reason: "invalid_request"}, nil + } + + result := mapDKIM2Assessment(assessment) + logger.Debug(). + Str("result", result.Result). + Str("reason", result.Reason). + Msg("DKIM2 verification completed") + return result, nil +} + +// bracketEnvelopePath turns the SMTP envelope into the bracketed form DKIM2 +// compares. A null reverse-path arrives as "" and must be sent as "<>". +// Local parts stay case-sensitive. Paths that are already bracketed are kept. +func bracketEnvelopePath(addr string) string { + addr = strings.TrimSpace(addr) + if addr == "" { + return "<>" + } + if len(addr) >= 2 && strings.HasPrefix(addr, "<") && strings.HasSuffix(addr, ">") { + return addr + } + return "<" + addr + ">" +} + +func canceled(ctx context.Context, err error) bool { + if ctx != nil && ctx.Err() != nil { + return true + } + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + +func mapDKIM2Assessment(assessment dkim2.VerificationAssessment) *domain.DKIM2Result { + if !assessment.Applicable() { + return &domain.DKIM2Result{Result: "none"} + } + verification, ok := assessment.Verification() + if !ok { + return &domain.DKIM2Result{Result: "permerror", Reason: "invalid_request"} + } + + checks := verification.Checks() + mappedChecks := make([]domain.DKIM2Check, 0, len(checks)) + for _, check := range checks { + mappedChecks = append(mappedChecks, domain.DKIM2Check{ + Class: string(check.Class()), + Reason: string(check.Reason()), + }) + } + + sets := verification.SignatureSets() + signatures := make([]domain.DKIM2Signature, 0, len(sets)) + for _, set := range sets { + signature := domain.DKIM2Signature{ + Algorithm: string(set.Algorithm()), + Status: string(set.Status()), + Reason: string(set.Reason()), + } + if selector, present := set.Selector(); present { + signature.Selector = selector + } + signatures = append(signatures, signature) + } + + target := verification.Target() + return &domain.DKIM2Result{ + Result: strings.ToLower(string(verification.State())), + Reason: string(verification.PrimaryReason()), + Scope: string(verification.Scope()), + HistoricalContent: string(verification.HistoricalContent()), + HistoricalSignatures: string(verification.HistoricalSignatures()), + Custody: string(verification.CustodyStructure()), + Sequence: target.Sequence(), + Instance: target.Instance(), + Checks: mappedChecks, + Signatures: signatures, + } +} diff --git a/internal/auth/dkim2_test.go b/internal/auth/dkim2_test.go new file mode 100644 index 0000000..529a14d --- /dev/null +++ b/internal/auth/dkim2_test.go @@ -0,0 +1,216 @@ +package auth + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + _ "embed" + "encoding/json" + "strings" + "testing" + "time" + + "inboundparse/internal/domain" + + "github.com/croessner/dkim2" +) + +//go:embed testdata/dkim2_ed25519.eml +var ed25519Fixture []byte + +//go:embed testdata/dkim2_null_sender.eml +var nullSenderFixture []byte + +const dkim2FixtureSeed = "inboundparse-dkim2-ed25519-test-key" + +func dkim2FixtureClock() time.Time { + return time.Unix(1_700_000_000, 0).UTC() +} + +type stubPublicKeyProvider struct { + public ed25519.PublicKey + missing bool +} + +func (s stubPublicKeyProvider) LookupPublicKey(_ context.Context, query dkim2.PublicKeyQuery) (dkim2.PublicKeyResult, error) { + if s.missing || query.Algorithm() != dkim2.AlgorithmEd25519SHA256 { + return dkim2.MissingPublicKey(query.Algorithm()), nil + } + return dkim2.FoundEd25519PublicKey(s.public), nil +} + +func testDKIM2Verifier(t *testing.T, missing bool) *dkim2.Verifier { + t.Helper() + seed := sha256.Sum256([]byte(dkim2FixtureSeed)) + private := ed25519.NewKeyFromSeed(seed[:]) + verifier, err := dkim2.NewVerifier( + stubPublicKeyProvider{public: private.Public().(ed25519.PublicKey), missing: missing}, + dkim2.WithoutSubdomainFromAuthorization(), + dkim2.WithVerificationClock(dkim2FixtureClock), + ) + if err != nil { + t.Fatalf("NewVerifier() error = %v", err) + } + return verifier +} + +func checkDKIM2(t *testing.T, verifier *dkim2.Verifier, raw, from string, to []string) *domain.DKIM2Result { + t.Helper() + result, err := NewDKIM2Checker(verifier).CheckDKIM2(context.Background(), raw, from, to, &MockLogger{}, &mockMetricsCollector{}) + if err != nil { + t.Fatalf("CheckDKIM2() error = %v", err) + } + if result == nil { + t.Fatal("CheckDKIM2() result is nil") + } + return result +} + +func TestDKIM2FixturesKeepCRLF(t *testing.T) { + for _, fixture := range [][]byte{ed25519Fixture, nullSenderFixture} { + if bytes.Count(fixture, []byte("\r\n")) == 0 { + t.Fatal("fixture has no CRLF") + } + if bytes.Contains(bytes.ReplaceAll(fixture, []byte("\r\n"), nil), []byte("\n")) { + t.Fatal("fixture has a bare LF, so the DKIM2 body hash would not survive checkout") + } + } +} + +func TestCheckDKIM2_None(t *testing.T) { + raw := "From: alice@example.test\r\nTo: bob@example.net\r\nSubject: unsigned\r\n\r\nbody\r\n" + result := checkDKIM2(t, testDKIM2Verifier(t, false), raw, "", []string{"bob@example.net"}) + if result.Result != "none" { + t.Fatalf("result = %q, want none", result.Result) + } + if result.Reason != "" || len(result.Signatures) != 0 || len(result.Checks) != 0 { + t.Fatalf("none result carried verification facts: %#v", result) + } +} + +func TestCheckDKIM2_Pass(t *testing.T) { + verifier := testDKIM2Verifier(t, false) + for _, from := range []string{"alice@example.test", "", " alice@example.test "} { + result := checkDKIM2(t, verifier, string(ed25519Fixture), from, []string{"bob@example.net"}) + if result.Result != "pass" || result.Reason != "none" { + t.Fatalf("from %q result = %s/%s, want pass/none", from, result.Result, result.Reason) + } + if result.Scope != "chain" || result.Sequence != 1 || result.Instance != 1 { + t.Fatalf("from %q target = scope %s sequence %d instance %d", from, result.Scope, result.Sequence, result.Instance) + } + if result.HistoricalContent != "complete" || result.HistoricalSignatures != "complete" || result.Custody != "not_present" { + t.Fatalf("from %q coverage = content %s signatures %s custody %s", from, result.HistoricalContent, result.HistoricalSignatures, result.Custody) + } + if len(result.Signatures) != 1 { + t.Fatalf("from %q signatures = %#v", from, result.Signatures) + } + signature := result.Signatures[0] + if signature.Algorithm != "ed25519-sha256" || signature.Status != "pass" || signature.Reason != "none" || signature.Selector != "" { + t.Fatalf("from %q signature = %#v", from, signature) + } + if len(result.Checks) == 0 { + t.Fatal("pass result has no checks") + } + encoded, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "example.test") || strings.Contains(string(encoded), "selector") { + t.Fatalf("pass result attested identity: %s", encoded) + } + } +} + +func TestCheckDKIM2_MissingKey(t *testing.T) { + result := checkDKIM2(t, testDKIM2Verifier(t, true), string(ed25519Fixture), "alice@example.test", []string{"bob@example.net"}) + if result.Result != "permerror" || result.Reason != "missing_key" { + t.Fatalf("result = %s/%s, want permerror/missing_key", result.Result, result.Reason) + } + if len(result.Signatures) != 1 || result.Signatures[0].Selector != "ed" { + t.Fatalf("signature = %#v", result.Signatures) + } +} + +func TestCheckDKIM2_EnvelopeMismatch(t *testing.T) { + verifier := testDKIM2Verifier(t, false) + cases := []struct { + name string + from string + to []string + }{ + {name: "mail from", from: "carol@example.test", to: []string{"bob@example.net"}}, + {name: "null sender", from: "", to: []string{"bob@example.net"}}, + {name: "local part case", from: "Alice@example.test", to: []string{"bob@example.net"}}, + {name: "recipient", from: "alice@example.test", to: []string{"carol@example.net", "bob@example.net"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result := checkDKIM2(t, verifier, string(ed25519Fixture), tc.from, tc.to) + if result.Result != "fail" || result.Reason != "envelope_mismatch" { + t.Fatalf("result = %s/%s, want fail/envelope_mismatch", result.Result, result.Reason) + } + }) + } +} + +func TestCheckDKIM2_NullSender(t *testing.T) { + verifier := testDKIM2Verifier(t, false) + for _, from := range []string{"", "<>", " <> "} { + result := checkDKIM2(t, verifier, string(nullSenderFixture), from, []string{""}) + if result.Result != "pass" || result.Reason != "none" { + t.Fatalf("from %q result = %s/%s, want pass/none", from, result.Result, result.Reason) + } + } + mismatched := checkDKIM2(t, verifier, string(nullSenderFixture), "alice@example.test", []string{"bob@example.net"}) + if mismatched.Result != "fail" || mismatched.Reason != "envelope_mismatch" { + t.Fatalf("address against null signature = %s/%s, want fail/envelope_mismatch", mismatched.Result, mismatched.Reason) + } +} + +func TestCheckDKIM2_Canceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + result, err := NewDKIM2Checker(testDKIM2Verifier(t, false)).CheckDKIM2( + ctx, string(ed25519Fixture), "alice@example.test", []string{"bob@example.net"}, &MockLogger{}, &mockMetricsCollector{}, + ) + if err != nil { + t.Fatalf("CheckDKIM2() error = %v", err) + } + if result == nil || result.Result != "temperror" || result.Reason != "unavailable" { + t.Fatalf("result = %#v, want temperror/unavailable", result) + } +} + +func TestCheckDKIM2_Deadline(t *testing.T) { + ctx, cancel := context.WithDeadline(context.Background(), time.Unix(1, 0)) + defer cancel() + result, err := NewDKIM2Checker(testDKIM2Verifier(t, false)).CheckDKIM2( + ctx, "From: alice@example.test\r\n\r\nbody\r\n", "", nil, &MockLogger{}, &mockMetricsCollector{}, + ) + if err != nil { + t.Fatalf("CheckDKIM2() error = %v", err) + } + if result == nil || result.Result != "temperror" || result.Reason != "unavailable" { + t.Fatalf("result = %#v, want temperror/unavailable", result) + } +} + +func TestCheckDKIM2_Disabled(t *testing.T) { + checker := NewAuthChecker(AuthConfig{EnableDKIM2: false}) + result, err := checker.CheckDKIM2(context.Background(), string(ed25519Fixture), "alice@example.test", []string{"bob@example.net"}, &MockLogger{}, &mockMetricsCollector{}) + if err != nil || result != nil { + t.Fatalf("disabled result = %#v err = %v", result, err) + } +} + +func TestCheckDKIM2_NilVerifier(t *testing.T) { + checker := NewAuthChecker(AuthConfig{EnableDKIM2: true}) + result, err := checker.CheckDKIM2(context.Background(), string(ed25519Fixture), "alice@example.test", []string{"bob@example.net"}, &MockLogger{}, &mockMetricsCollector{}) + if err != nil { + t.Fatalf("CheckDKIM2() error = %v", err) + } + if result == nil || result.Result != "permerror" || result.Reason != "invalid_request" { + t.Fatalf("result = %#v, want permerror/invalid_request", result) + } +} diff --git a/internal/auth/dkim_test.go b/internal/auth/dkim_test.go index 6f564f0..a2b19e9 100644 --- a/internal/auth/dkim_test.go +++ b/internal/auth/dkim_test.go @@ -20,6 +20,8 @@ func (m *mockMetricsCollector) TrackWebhookRetry(success bool) func (m *mockMetricsCollector) TrackWebhookError(errorType string) {} func (m *mockMetricsCollector) TrackSPFDuration(duration time.Duration) {} func (m *mockMetricsCollector) TrackDKIMDuration(duration time.Duration) {} +func (m *mockMetricsCollector) TrackDKIM2Result(result, reason string) {} +func (m *mockMetricsCollector) TrackDKIM2Duration(duration time.Duration) {} func (m *mockMetricsCollector) TrackDMARCDuration(duration time.Duration) {} func (m *mockMetricsCollector) TrackSPFResultByDomain(domain, result string) {} func (m *mockMetricsCollector) TrackDKIMResultByDomain(domain, result string) {} diff --git a/internal/auth/interfaces.go b/internal/auth/interfaces.go index afa35f7..6eae0e0 100644 --- a/internal/auth/interfaces.go +++ b/internal/auth/interfaces.go @@ -6,6 +6,7 @@ import ( "inboundparse/internal/observability" "net" + "github.com/croessner/dkim2" "github.com/mnako/letters" ) @@ -19,6 +20,11 @@ type DKIMChecker interface { CheckDKIM(ctx context.Context, rawMessage string, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DKIMResult, error) } +// DKIM2Checker verifies inbound DKIM2 signatures. It does not sign. +type DKIM2Checker interface { + CheckDKIM2(ctx context.Context, rawMessage string, from string, to []string, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DKIM2Result, error) +} + // DMARCChecker interface for DMARC validation type DMARCChecker interface { CheckDMARC(ctx context.Context, rawMessage string, headers letters.Headers, spfResult *domain.SPFResult, dkimResult *domain.DKIMResult, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DMARCResult, error) @@ -28,14 +34,19 @@ type DMARCChecker interface { type AuthChecker interface { SPFChecker DKIMChecker + DKIM2Checker DMARCChecker } -// AuthConfig holds authentication configuration +// AuthConfig holds authentication configuration. +// DKIM2Verifier is the process-wide verifier. Production builds it from DNS. +// Tests pass a verifier backed by a stub public-key provider. type AuthConfig struct { - EnableSPF bool - EnableDKIM bool - EnableDMARC bool + EnableSPF bool + EnableDKIM bool + EnableDKIM2 bool + EnableDMARC bool + DKIM2Verifier *dkim2.Verifier } // NewAuthChecker creates a new authentication checker with all enabled checkers @@ -44,6 +55,7 @@ func NewAuthChecker(config AuthConfig) AuthChecker { config: config, spf: NewSPFChecker(), dkim: NewDKIMChecker(), + dkim2: NewDKIM2Checker(config.DKIM2Verifier), dmarc: NewDMARCChecker(), } } @@ -53,6 +65,7 @@ type authChecker struct { config AuthConfig spf SPFChecker dkim DKIMChecker + dkim2 DKIM2Checker dmarc DMARCChecker } @@ -72,6 +85,14 @@ func (a *authChecker) CheckDKIM(ctx context.Context, rawMessage string, logger o return a.dkim.CheckDKIM(ctx, rawMessage, logger, metrics) } +// CheckDKIM2 performs DKIM2 verification. DMARC does not consume this result. +func (a *authChecker) CheckDKIM2(ctx context.Context, rawMessage string, from string, to []string, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DKIM2Result, error) { + if !a.config.EnableDKIM2 { + return nil, nil + } + return a.dkim2.CheckDKIM2(ctx, rawMessage, from, to, logger, metrics) +} + // CheckDMARC performs DMARC authentication check func (a *authChecker) CheckDMARC(ctx context.Context, rawMessage string, headers letters.Headers, spfResult *domain.SPFResult, dkimResult *domain.DKIMResult, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DMARCResult, error) { if !a.config.EnableDMARC { diff --git a/internal/auth/testdata/dkim2_ed25519.eml b/internal/auth/testdata/dkim2_ed25519.eml new file mode 100644 index 0000000..3b8cdba --- /dev/null +++ b/internal/auth/testdata/dkim2_ed25519.eml @@ -0,0 +1,15 @@ +Message-Instance: m=1; + h=sha256:0N0mP52lRu9M/s28hUt1Frp8jB2K1Xco97u3Vg1RUeo=:8vV4yHPoxe2+2N4LaBuUlaiBjwc5mMbTkiDTdFo58ks=; +DKIM2-Signature: i=1; + m=1; + t=1700000000; + mf=PGFsaWNlQGV4YW1wbGUudGVzdD4=; + rt=PGJvYkBleGFtcGxlLm5ldD4=; + d=example.test; + s=ed:ed25519-sha256:5K7gNwDojHjohkNAWmF1N9BTHtn51ticrSkw8ryzKZXfOyeWtiA6IIE7aZZQKQ+c + 3t+q8+RyxkDCHG7GuUhMAw==; +From: alice@example.test +To: bob@example.net +Subject: inboundparse dkim2 fixture + +Hello DKIM2. diff --git a/internal/auth/testdata/dkim2_null_sender.eml b/internal/auth/testdata/dkim2_null_sender.eml new file mode 100644 index 0000000..a004059 --- /dev/null +++ b/internal/auth/testdata/dkim2_null_sender.eml @@ -0,0 +1,15 @@ +Message-Instance: m=1; + h=sha256:0N0mP52lRu9M/s28hUt1Frp8jB2K1Xco97u3Vg1RUeo=:8vV4yHPoxe2+2N4LaBuUlaiBjwc5mMbTkiDTdFo58ks=; +DKIM2-Signature: i=1; + m=1; + t=1700000000; + mf=PD4=; + rt=PGJvYkBleGFtcGxlLm5ldD4=; + d=example.test; + s=ed:ed25519-sha256:L9A054Bluv25nb8TVqdXycNQWiMn7SJeUlGaBNC2C/BQFck9w1fe/nkTVg83qS9D + hYiqOnY126ANeeknfYECCA==; +From: alice@example.test +To: bob@example.net +Subject: inboundparse dkim2 fixture + +Hello DKIM2. diff --git a/internal/auth/testdata/genfixture/main.go b/internal/auth/testdata/genfixture/main.go new file mode 100644 index 0000000..2a3980c --- /dev/null +++ b/internal/auth/testdata/genfixture/main.go @@ -0,0 +1,142 @@ +// Command genfixture writes the committed DKIM2 test messages. +// It is not part of inbound verification. Run it from the module root: +// +// go run ./internal/auth/testdata/genfixture +package main + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "flag" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/croessner/dkim2" +) + +const fixtureSeed = "inboundparse-dkim2-ed25519-test-key" + +func fixtureClock() time.Time { + return time.Unix(1_700_000_000, 0).UTC() +} + +func main() { + dir := flag.String("dir", "internal/auth/testdata", "directory for the committed fixtures") + flag.Parse() + + raw := []byte("From: alice@example.test\r\nTo: bob@example.net\r\nSubject: inboundparse dkim2 fixture\r\n\r\nHello DKIM2.\r\n") + ordinary, err := sign(raw, []byte(""), false) + if err != nil { + fmt.Fprintf(os.Stderr, "sign ordinary: %v\n", err) + os.Exit(1) + } + nullSender, err := sign(raw, []byte("<>"), true) + if err != nil { + fmt.Fprintf(os.Stderr, "sign null sender: %v\n", err) + os.Exit(1) + } + if err := os.MkdirAll(*dir, 0o755); err != nil { + fmt.Fprintf(os.Stderr, "mkdir: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(filepath.Join(*dir, "dkim2_ed25519.eml"), ordinary, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "write ordinary: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(filepath.Join(*dir, "dkim2_null_sender.eml"), nullSender, 0o644); err != nil { + fmt.Fprintf(os.Stderr, "write null sender: %v\n", err) + os.Exit(1) + } +} + +func sign(raw, reverse []byte, nullSender bool) ([]byte, error) { + seed := sha256.Sum256([]byte(fixtureSeed)) + private := ed25519.NewKeyFromSeed(seed[:]) + public := private.Public().(ed25519.PublicKey) + provider := &fixtureSigner{private: private, public: public} + + signer, err := dkim2.NewSigner( + provider, + dkim2.NewRequestRouteAuthority(), + provider, + provider, + dkim2.WithSigningClock(fixtureClock), + dkim2.WithoutSignerSubdomainFromAuthorization(), + ) + if err != nil { + return nil, err + } + + handle, err := dkim2.NewPrivateKeyHandle([]byte("ed-key")) + if err != nil { + return nil, err + } + credential, err := dkim2.NewEd25519SigningCredential("ed", public, handle) + if err != nil { + return nil, err + } + profile, err := dkim2.NewEd25519SigningProfile("example.test", credential) + if err != nil { + return nil, err + } + + forwards := [][]byte{[]byte("")} + source, err := dkim2.NewSigningSource(raw) + if err != nil { + return nil, err + } + entry, err := dkim2.NewOriginatorRouteEntry(source, reverse, forwards, dkim2.RouteDisclosureSingle, []byte("local-route")) + if err != nil { + return nil, err + } + fanout, err := dkim2.NewRouteFanoutRequest([]dkim2.RouteEntry{entry}) + if err != nil { + return nil, err + } + plan, tickets, err := signer.PlanRouteFanout(context.Background(), fanout) + if err != nil || !plan.Valid() || len(tickets) != 1 { + return nil, fmt.Errorf("plan route: valid=%t tickets=%d err=%v", plan.Valid(), len(tickets), err) + } + + var request dkim2.OriginatorSigningRequest + if nullSender { + request = dkim2.NewHeaderFromNullSenderSigningRequest(raw, forwards, tickets[0], profile, dkim2.SigningMetadata{}, dkim2.SigningTransportFinalNetworkPreDotStuffing) + } else { + request = dkim2.NewOriginatorSigningRequest(raw, reverse, forwards, tickets[0], profile, dkim2.SigningMetadata{}, dkim2.SigningTransportFinalNetworkPreDotStuffing) + } + result, recovery, err := signer.SignOriginator(context.Background(), request) + if err != nil || recovery.Valid() || !result.Valid() { + return nil, fmt.Errorf("sign: valid=%t recovery=%t err=%v", result.Valid(), recovery.Valid(), err) + } + signed, ok := result.Unrestricted() + if !ok || !signed.Valid() { + return nil, fmt.Errorf("signed output is restricted") + } + return signed.Bytes(), nil +} + +type fixtureSigner struct { + private ed25519.PrivateKey + public ed25519.PublicKey +} + +func (s *fixtureSigner) LookupPublicKey(_ context.Context, query dkim2.PublicKeyQuery) (dkim2.PublicKeyResult, error) { + if query.Algorithm() != dkim2.AlgorithmEd25519SHA256 { + return dkim2.MissingPublicKey(query.Algorithm()), nil + } + return dkim2.FoundEd25519PublicKey(s.public), nil +} + +func (s *fixtureSigner) SignDigest(_ context.Context, _ dkim2.PrivateKeyHandle, request dkim2.PrivateKeySignRequest) (dkim2.PrivateKeySignResult, error) { + if request.Algorithm() != dkim2.AlgorithmEd25519SHA256 { + return dkim2.PrivateKeySignResult{}, fmt.Errorf("unexpected algorithm %s", request.Algorithm()) + } + return dkim2.NewPrivateKeySignResult(ed25519.Sign(s.private, request.MessageRepresentative())), nil +} + +func (s *fixtureSigner) Authorize(_ context.Context, query dkim2.SigningAuthorizationQuery) (dkim2.SigningAuthorizationResult, error) { + return dkim2.AuthorizeSigning(query), nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 6120375..f6df02a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -43,6 +43,7 @@ type Config struct { // Authentication Configuration EnableSPF bool EnableDKIM bool + EnableDKIM2 bool EnableDMARC bool // Observability Configuration @@ -179,6 +180,7 @@ func New(options ...Option) *Config { SentryEnv: DefaultSentryEnv, EnableSPF: true, EnableDKIM: true, + EnableDKIM2: true, EnableDMARC: true, // Webhook defaults WebhookMaxRetries: DefaultWebhookMaxRetries, @@ -221,6 +223,7 @@ func LoadFromFlags() (*Config, error) { writeTimeout = flag.Int("write-timeout", int(DefaultWriteTimeout.Seconds()), "Write timeout in seconds") enableSPF = flag.Bool("enable-spf", true, "Enable SPF validation") enableDKIM = flag.Bool("enable-dkim", true, "Enable DKIM validation") + enableDKIM2 = flag.Bool("enable-dkim2", true, "Enable DKIM2 verification") enableDMARC = flag.Bool("enable-dmarc", true, "Enable DMARC validation") certFile = flag.String("cert-file", "", "TLS certificate file") keyFile = flag.String("key-file", "", "TLS key file") @@ -272,6 +275,7 @@ func LoadFromFlags() (*Config, error) { verboseVal := utils.GetEnvBool("VERBOSE", *verbose) enableSPFVal := utils.GetEnvBool("ENABLE_SPF", *enableSPF) enableDKIMVal := utils.GetEnvBool("ENABLE_DKIM", *enableDKIM) + enableDKIM2Val := utils.GetEnvBool("ENABLE_DKIM2", *enableDKIM2) enableDMARCVal := utils.GetEnvBool("ENABLE_DMARC", *enableDMARC) enableSentryVal := utils.GetEnvBool("ENABLE_SENTRY", *enableSentry) sentryDSNVal := utils.GetEnv("SENTRY_DSN", *sentryDSN) @@ -295,6 +299,7 @@ func LoadFromFlags() (*Config, error) { WithAuth(enableSPFVal, enableDKIMVal, enableDMARCVal), WithVerbose(verboseVal), ) + config.EnableDKIM2 = enableDKIM2Val // Set webhook retry and rate limiting configuration config.WebhookMaxRetries = webhookMaxRetriesVal diff --git a/internal/config/config_test.go b/internal/config/config_test.go index a20af0d..484b67e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -39,6 +39,9 @@ func TestNew_DefaultValues(t *testing.T) { if !cfg.EnableDKIM { t.Error("Expected EnableDKIM to be true") } + if !cfg.EnableDKIM2 { + t.Error("Expected EnableDKIM2 to be true") + } if !cfg.EnableDMARC { t.Error("Expected EnableDMARC to be true") } diff --git a/internal/domain/email.go b/internal/domain/email.go index 9392f47..d0c04b3 100644 --- a/internal/domain/email.go +++ b/internal/domain/email.go @@ -13,10 +13,13 @@ type EmailAddress struct { Name string `json:"name,omitempty"` } -// AuthenticationResults contains the results of email authentication checks +// AuthenticationResults contains the results of email authentication checks. +// DKIM2 is draft-ietf-dkim-dkim2-spec-09 and stays separate from RFC 6376 DKIM +// and RFC 7489 DMARC. type AuthenticationResults struct { SPF *SPFResult `json:"spf,omitempty"` DKIM *DKIMResult `json:"dkim,omitempty"` + DKIM2 *DKIM2Result `json:"dkim2,omitempty"` DMARC *DMARCResult `json:"dmarc,omitempty"` } @@ -60,6 +63,38 @@ type DKIMResult struct { Error string `json:"error,omitempty"` } +// DKIM2Check is one closed-vocabulary fact from DKIM2 verification. +type DKIM2Check struct { + Class string `json:"class"` + Reason string `json:"reason"` +} + +// DKIM2Signature is one signature-set fact. Selector is present only when the +// library exposes it, which is for a set that did not pass. +type DKIM2Signature struct { + Algorithm string `json:"algorithm"` + Status string `json:"status"` + Reason string `json:"reason"` + Selector string `json:"selector,omitempty"` +} + +// DKIM2Result is the draft-09 verification state reported on the webhook. +// Result is none, pass, fail, temperror, or permerror. Reason is the library +// reason code, or unavailable when the check was canceled. The signing domain +// is withheld on purpose. +type DKIM2Result struct { + Result string `json:"result"` + Reason string `json:"reason,omitempty"` + Scope string `json:"scope,omitempty"` + HistoricalContent string `json:"historical_content,omitempty"` + HistoricalSignatures string `json:"historical_signatures,omitempty"` + Custody string `json:"custody,omitempty"` + Sequence uint64 `json:"sequence,omitempty"` + Instance uint64 `json:"instance,omitempty"` + Checks []DKIM2Check `json:"checks,omitempty"` + Signatures []DKIM2Signature `json:"signatures,omitempty"` +} + // DMARCResult contains DMARC validation results type DMARCResult struct { Result string `json:"result"` diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index 961cdcb..0591c0b 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -32,6 +32,7 @@ type MetricsCollector interface { TrackMessage(size int64, success bool) TrackSPFResult(result string) TrackDKIMResult(valid bool) + TrackDKIM2Result(result, reason string) TrackDMARCResult(result, policy string) TrackWebhookRequest(statusCode int, duration time.Duration) TrackWebhookRequestSize(requestSize, responseSize int64) @@ -39,6 +40,7 @@ type MetricsCollector interface { TrackWebhookError(errorType string) TrackSPFDuration(duration time.Duration) TrackDKIMDuration(duration time.Duration) + TrackDKIM2Duration(duration time.Duration) TrackDMARCDuration(duration time.Duration) TrackSPFResultByDomain(domain, result string) TrackDKIMResultByDomain(domain, result string) @@ -87,10 +89,12 @@ type metricsCollector struct { // Authentication metrics spfResults *prometheus.CounterVec dkimResults *prometheus.CounterVec + dkim2Results *prometheus.CounterVec dmarcResults *prometheus.CounterVec dmarcPolicies *prometheus.CounterVec authChecksSPF prometheus.Counter authChecksDKIM prometheus.Counter + authChecksDKIM2 prometheus.Counter authChecksDMARC prometheus.Counter // Webhook metrics @@ -104,6 +108,7 @@ type metricsCollector struct { // Authentication duration metrics spfDuration prometheus.Histogram dkimDuration prometheus.Histogram + dkim2Duration prometheus.Histogram dmarcDuration prometheus.Histogram // Domain-based authentication metrics @@ -208,6 +213,10 @@ func newMetricsCollectorWithFactory(factory promauto.Factory) MetricsCollector { Name: "dkim_results_total", Help: "Total DKIM validation results", }, []string{"result"}), + dkim2Results: factory.NewCounterVec(prometheus.CounterOpts{ + Name: "dkim2_results_total", + Help: "Total DKIM2 verification results", + }, []string{"result", "reason"}), dmarcResults: factory.NewCounterVec(prometheus.CounterOpts{ Name: "dmarc_results_total", Help: "Total DMARC validation results", @@ -224,6 +233,10 @@ func newMetricsCollectorWithFactory(factory promauto.Factory) MetricsCollector { Name: "auth_checks_dkim_total", Help: "Total DKIM authentication checks performed", }), + authChecksDKIM2: factory.NewCounter(prometheus.CounterOpts{ + Name: "auth_checks_dkim2_total", + Help: "Total DKIM2 authentication checks performed", + }), authChecksDMARC: factory.NewCounter(prometheus.CounterOpts{ Name: "auth_checks_dmarc_total", Help: "Total DMARC authentication checks performed", @@ -269,6 +282,11 @@ func newMetricsCollectorWithFactory(factory promauto.Factory) MetricsCollector { Help: "Duration of DKIM authentication checks in seconds", Buckets: prometheus.DefBuckets, }), + dkim2Duration: factory.NewHistogram(prometheus.HistogramOpts{ + Name: "dkim2_check_duration_seconds", + Help: "Duration of DKIM2 authentication checks in seconds", + Buckets: prometheus.DefBuckets, + }), dmarcDuration: factory.NewHistogram(prometheus.HistogramOpts{ Name: "dmarc_check_duration_seconds", Help: "Duration of DMARC authentication checks in seconds", @@ -418,6 +436,17 @@ func (m *metricsCollector) TrackDKIMResult(valid bool) { } } +// TrackDKIM2Result records a DKIM2 result and reason. An empty reason is stored +// as none so the label stays in the closed vocabulary. Results are not labeled +// by signing domain. +func (m *metricsCollector) TrackDKIM2Result(result, reason string) { + m.authChecksDKIM2.Inc() + if reason == "" { + reason = "none" + } + m.dkim2Results.WithLabelValues(result, reason).Inc() +} + // TrackDMARCResult records DMARC validation result and policy func (m *metricsCollector) TrackDMARCResult(result, policy string) { m.authChecksDMARC.Inc() @@ -463,6 +492,11 @@ func (m *metricsCollector) TrackDKIMDuration(duration time.Duration) { m.dkimDuration.Observe(duration.Seconds()) } +// TrackDKIM2Duration records DKIM2 check duration +func (m *metricsCollector) TrackDKIM2Duration(duration time.Duration) { + m.dkim2Duration.Observe(duration.Seconds()) +} + // TrackDMARCDuration records DMARC check duration func (m *metricsCollector) TrackDMARCDuration(duration time.Duration) { m.dmarcDuration.Observe(duration.Seconds()) @@ -713,6 +747,9 @@ func (n *NoOpMetricsCollector) TrackSPFResult(result string) {} // TrackDKIMResult does nothing func (n *NoOpMetricsCollector) TrackDKIMResult(valid bool) {} +// TrackDKIM2Result does nothing +func (n *NoOpMetricsCollector) TrackDKIM2Result(result, reason string) {} + // TrackDMARCResult does nothing func (n *NoOpMetricsCollector) TrackDMARCResult(result, policy string) {} @@ -734,6 +771,9 @@ func (n *NoOpMetricsCollector) TrackSPFDuration(duration time.Duration) {} // TrackDKIMDuration does nothing func (n *NoOpMetricsCollector) TrackDKIMDuration(duration time.Duration) {} +// TrackDKIM2Duration does nothing +func (n *NoOpMetricsCollector) TrackDKIM2Duration(duration time.Duration) {} + // TrackDMARCDuration does nothing func (n *NoOpMetricsCollector) TrackDMARCDuration(duration time.Duration) {} diff --git a/internal/observability/metrics_test.go b/internal/observability/metrics_test.go index 62d1f7f..2db880f 100644 --- a/internal/observability/metrics_test.go +++ b/internal/observability/metrics_test.go @@ -10,6 +10,7 @@ import ( "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" + dto "github.com/prometheus/client_model/go" ) // createTestMetricsCollector creates a metrics collector using a custom registry for testing @@ -405,6 +406,87 @@ func TestMetricsCollector_TrackDKIMResult(t *testing.T) { collector.TrackDKIMResult(false) } +func TestMetricsCollector_TrackDKIM2Result(t *testing.T) { + registry := prometheus.NewRegistry() + collector := NewMetricsCollectorWithRegistry(registry) + + collector.TrackDKIM2Result("pass", "none") + collector.TrackDKIM2Result("none", "") + collector.TrackDKIM2Result("permerror", "missing_key") + collector.TrackDKIM2Duration(time.Millisecond) + + if got := counterValue(t, registry, "auth_checks_dkim2_total", nil); got != 3 { + t.Fatalf("auth_checks_dkim2_total = %v, want 3", got) + } + if got := counterValue(t, registry, "dkim2_results_total", map[string]string{"result": "pass", "reason": "none"}); got != 1 { + t.Fatalf("pass/none = %v, want 1", got) + } + if got := counterValue(t, registry, "dkim2_results_total", map[string]string{"result": "none", "reason": "none"}); got != 1 { + t.Fatalf("empty reason was not stored as none: %v", got) + } + if got := counterValue(t, registry, "dkim2_results_total", map[string]string{"result": "permerror", "reason": "missing_key"}); got != 1 { + t.Fatalf("permerror/missing_key = %v, want 1", got) + } + if got := histogramCount(t, registry, "dkim2_check_duration_seconds"); got != 1 { + t.Fatalf("dkim2 duration samples = %v, want 1", got) + } + + noop := &NoOpMetricsCollector{} + noop.TrackDKIM2Result("pass", "none") + noop.TrackDKIM2Duration(time.Millisecond) +} + +func counterValue(t *testing.T, registry *prometheus.Registry, name string, labels map[string]string) float64 { + t.Helper() + for _, family := range gatherFamilies(t, registry) { + if family.GetName() != name { + continue + } + for _, metric := range family.GetMetric() { + if labelValuesMatch(metric.GetLabel(), labels) { + return metric.GetCounter().GetValue() + } + } + } + t.Fatalf("counter %s %v not found", name, labels) + return 0 +} + +func histogramCount(t *testing.T, registry *prometheus.Registry, name string) uint64 { + t.Helper() + for _, family := range gatherFamilies(t, registry) { + if family.GetName() != name { + continue + } + for _, metric := range family.GetMetric() { + return metric.GetHistogram().GetSampleCount() + } + } + t.Fatalf("histogram %s not found", name) + return 0 +} + +func gatherFamilies(t *testing.T, registry *prometheus.Registry) []*dto.MetricFamily { + t.Helper() + families, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + return families +} + +func labelValuesMatch(got []*dto.LabelPair, want map[string]string) bool { + if len(got) != len(want) { + return false + } + for _, label := range got { + if want[label.GetName()] != label.GetValue() { + return false + } + } + return true +} + func TestMetricsCollector_TrackDMARCResult(t *testing.T) { // Create a test-specific metrics collector using a custom registry registry := prometheus.NewRegistry() diff --git a/internal/processor/processor.go b/internal/processor/processor.go index 6688fc2..77b5825 100644 --- a/internal/processor/processor.go +++ b/internal/processor/processor.go @@ -19,6 +19,7 @@ import ( type ProcessorConfig struct { EnableSPF bool EnableDKIM bool + EnableDKIM2 bool EnableDMARC bool } @@ -130,9 +131,9 @@ func (mp *MessageProcessor) ProcessMessage(ctx context.Context, r io.Reader, fro } // Perform authentication checks - if mp.config.EnableSPF || mp.config.EnableDKIM || mp.config.EnableDMARC { + if mp.config.EnableSPF || mp.config.EnableDKIM || mp.config.EnableDKIM2 || mp.config.EnableDMARC { authStart := time.Now() - authResults, err := mp.performAuthenticationChecks(ctx, rawMessage, email.Headers, from, remoteIP, sessionLogger) + authResults, err := mp.performAuthenticationChecks(ctx, rawMessage, email.Headers, from, to, remoteIP, sessionLogger) authDuration := time.Since(authStart) mp.metrics.TrackMessageAuthDuration(authDuration) @@ -175,25 +176,28 @@ func (mp *MessageProcessor) ProcessMessage(ctx context.Context, r io.Reader, fro } // performAuthenticationChecks runs all enabled authentication checks. -// SPF and DKIM run concurrently; DMARC runs after both since it depends on their results. -func (mp *MessageProcessor) performAuthenticationChecks(ctx context.Context, rawMessage string, headers letters.Headers, from string, remoteIP net.IP, sessionLogger observability.Logger) (domain.AuthenticationResults, error) { +// SPF, DKIM, and DKIM2 run concurrently. DMARC waits on SPF and DKIM only. +func (mp *MessageProcessor) performAuthenticationChecks(ctx context.Context, rawMessage string, headers letters.Headers, from string, to []string, remoteIP net.IP, sessionLogger observability.Logger) (domain.AuthenticationResults, error) { results := domain.AuthenticationResults{} sessionLogger.Debug(). Bool("spf_enabled", mp.config.EnableSPF). Bool("dkim_enabled", mp.config.EnableDKIM). + Bool("dkim2_enabled", mp.config.EnableDKIM2). Bool("dmarc_enabled", mp.config.EnableDMARC). Msg("Starting authentication checks") senderDomain := auth.ExtractDomainFromEmail(from) - // Run SPF and DKIM concurrently since they are independent + // SPF, RFC 6376 DKIM, and DKIM2 are independent. var ( - spfResult *domain.SPFResult - dkimResult *domain.DKIMResult - spfErr error - dkimErr error - wg sync.WaitGroup + spfResult *domain.SPFResult + dkimResult *domain.DKIMResult + dkim2Result *domain.DKIM2Result + spfErr error + dkimErr error + dkim2Err error + wg sync.WaitGroup ) if mp.config.EnableSPF { @@ -212,6 +216,14 @@ func (mp *MessageProcessor) performAuthenticationChecks(ctx context.Context, raw }() } + if mp.config.EnableDKIM2 { + wg.Add(1) + go func() { + defer wg.Done() + dkim2Result, dkim2Err = mp.authChecker.CheckDKIM2(ctx, rawMessage, from, to, sessionLogger, mp.metrics) + }() + } + wg.Wait() if spfErr != nil { @@ -237,7 +249,18 @@ func (mp *MessageProcessor) performAuthenticationChecks(ctx context.Context, raw mp.metrics.TrackDKIMResult(dkimResult.Valid) } - // DMARC depends on SPF and DKIM results, so it runs after both complete + if dkim2Err != nil { + sessionLogger.Warn().Err(dkim2Err).Msg("DKIM2 check failed") + mp.sentry.CaptureError(dkim2Err, map[string]interface{}{ + "check_type": "dkim2", + "from": from, + }) + } else if dkim2Result != nil { + results.DKIM2 = dkim2Result + mp.metrics.TrackDKIM2Result(dkim2Result.Result, dkim2Result.Reason) + } + + // DMARC depends on SPF and RFC 6376 DKIM. DKIM2 is not an input. if mp.config.EnableDMARC { dmarcResult, err := mp.authChecker.CheckDMARC(ctx, rawMessage, headers, results.SPF, results.DKIM, sessionLogger, mp.metrics) if err != nil { @@ -252,11 +275,16 @@ func (mp *MessageProcessor) performAuthenticationChecks(ctx context.Context, raw } } - sessionLogger.Info(). + completed := sessionLogger.Info(). Interface("spf", results.SPF). Interface("dkim", results.DKIM). - Interface("dmarc", results.DMARC). - Msg("Authentication checks completed") + Interface("dmarc", results.DMARC) + if results.DKIM2 != nil { + completed = completed. + Str("dkim2_result", results.DKIM2.Result). + Str("dkim2_reason", results.DKIM2.Reason) + } + completed.Msg("Authentication checks completed") return results, nil } diff --git a/internal/processor/processor_test.go b/internal/processor/processor_test.go index 2eeaa87..aca79a8 100644 --- a/internal/processor/processor_test.go +++ b/internal/processor/processor_test.go @@ -19,10 +19,14 @@ import ( type mockAuthChecker struct { spfResult *domain.SPFResult dkimResult *domain.DKIMResult + dkim2Result *domain.DKIM2Result dmarcResult *domain.DMARCResult spfErr error dkimErr error + dkim2Err error dmarcErr error + dkim2From string + dkim2To []string } func (m *mockAuthChecker) CheckSPF(ctx context.Context, remoteIP net.IP, domainName, from string, logger observability.Logger, metrics observability.MetricsCollector) (*domain.SPFResult, error) { @@ -33,15 +37,23 @@ func (m *mockAuthChecker) CheckDKIM(ctx context.Context, rawMessage string, logg return m.dkimResult, m.dkimErr } +func (m *mockAuthChecker) CheckDKIM2(ctx context.Context, rawMessage string, from string, to []string, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DKIM2Result, error) { + m.dkim2From = from + m.dkim2To = append([]string(nil), to...) + return m.dkim2Result, m.dkim2Err +} + func (m *mockAuthChecker) CheckDMARC(ctx context.Context, rawMessage string, headers letters.Headers, spfResult *domain.SPFResult, dkimResult *domain.DKIMResult, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DMARCResult, error) { return m.dmarcResult, m.dmarcErr } type mockWebhookSender struct { - err error + err error + payload interface{} } func (m *mockWebhookSender) SendWebhook(ctx context.Context, payload interface{}) error { + m.payload = payload return m.err } @@ -59,6 +71,8 @@ func (m *mockMetricsCollector) TrackWebhookRetry(success bool) func (m *mockMetricsCollector) TrackWebhookError(errorType string) {} func (m *mockMetricsCollector) TrackSPFDuration(duration time.Duration) {} func (m *mockMetricsCollector) TrackDKIMDuration(duration time.Duration) {} +func (m *mockMetricsCollector) TrackDKIM2Result(result, reason string) {} +func (m *mockMetricsCollector) TrackDKIM2Duration(duration time.Duration) {} func (m *mockMetricsCollector) TrackDMARCDuration(duration time.Duration) {} func (m *mockMetricsCollector) TrackSPFResultByDomain(domain, result string) {} func (m *mockMetricsCollector) TrackDKIMResultByDomain(domain, result string) {} @@ -150,6 +164,42 @@ func TestMessageProcessor_ProcessMessage_Success(t *testing.T) { } } +func TestMessageProcessor_ProcessMessage_DKIM2Envelope(t *testing.T) { + authChecker := &mockAuthChecker{ + dkim2Result: &domain.DKIM2Result{Result: "none"}, + } + webhookSender := &mockWebhookSender{} + processor := NewMessageProcessor( + ProcessorConfig{EnableDKIM2: true}, + authChecker, + webhookSender, + &mockMetricsCollector{}, + &mockSentryClient{}, + &mockLogger{}, + ) + + message := "From: test@example.com\r\nTo: recipient@example.com\r\nSubject: Test\r\n\r\nTest message" + from := "test@example.com" + to := []string{"recipient@example.com", "other@example.com"} + err := processor.ProcessMessage(context.Background(), strings.NewReader(message), from, to, "test-session", net.ParseIP("192.168.1.1")) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + if authChecker.dkim2From != from { + t.Fatalf("DKIM2 from = %q, want %q", authChecker.dkim2From, from) + } + if len(authChecker.dkim2To) != len(to) || authChecker.dkim2To[0] != to[0] || authChecker.dkim2To[1] != to[1] { + t.Fatalf("DKIM2 recipients = %#v, want %#v", authChecker.dkim2To, to) + } + payload, ok := webhookSender.payload.(*domain.WebhookPayload) + if !ok || payload.AuthenticationResults.DKIM2 == nil || payload.AuthenticationResults.DKIM2.Result != "none" { + t.Fatalf("webhook dkim2 = %#v", webhookSender.payload) + } + if payload.AuthenticationResults.DMARC != nil || payload.AuthenticationResults.DKIM != nil { + t.Fatal("DKIM2 must not populate DKIM or DMARC") + } +} + func TestMessageProcessor_ProcessMessage_WebhookError(t *testing.T) { config := ProcessorConfig{ EnableSPF: true, @@ -278,6 +328,9 @@ func TestProcessorConfig_DefaultValues(t *testing.T) { if config.EnableDKIM { t.Error("Expected EnableDKIM to be false by default") } + if config.EnableDKIM2 { + t.Error("Expected EnableDKIM2 to be false by default") + } if config.EnableDMARC { t.Error("Expected EnableDMARC to be false by default") } @@ -287,6 +340,7 @@ func TestProcessorConfig_WithValues(t *testing.T) { config := ProcessorConfig{ EnableSPF: true, EnableDKIM: true, + EnableDKIM2: true, EnableDMARC: true, } @@ -296,6 +350,9 @@ func TestProcessorConfig_WithValues(t *testing.T) { if !config.EnableDKIM { t.Error("Expected EnableDKIM to be true") } + if !config.EnableDKIM2 { + t.Error("Expected EnableDKIM2 to be true") + } if !config.EnableDMARC { t.Error("Expected EnableDMARC to be true") } diff --git a/internal/smtp/backend_test.go b/internal/smtp/backend_test.go index 87330ff..f9ee5aa 100644 --- a/internal/smtp/backend_test.go +++ b/internal/smtp/backend_test.go @@ -47,6 +47,10 @@ func (m *mockAuthChecker) CheckDKIM(ctx context.Context, rawMessage string, logg return nil, nil } +func (m *mockAuthChecker) CheckDKIM2(ctx context.Context, rawMessage string, from string, to []string, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DKIM2Result, error) { + return nil, nil +} + func (m *mockAuthChecker) CheckDMARC(ctx context.Context, rawMessage string, headers letters.Headers, spfResult *domain.SPFResult, dkimResult *domain.DKIMResult, logger observability.Logger, metrics observability.MetricsCollector) (*domain.DMARCResult, error) { return nil, nil } @@ -85,6 +89,8 @@ func (m *mockMetricsCollector) TrackWebhookRetry(success bool) func (m *mockMetricsCollector) TrackWebhookError(errorType string) {} func (m *mockMetricsCollector) TrackSPFDuration(duration time.Duration) {} func (m *mockMetricsCollector) TrackDKIMDuration(duration time.Duration) {} +func (m *mockMetricsCollector) TrackDKIM2Result(result, reason string) {} +func (m *mockMetricsCollector) TrackDKIM2Duration(duration time.Duration) {} func (m *mockMetricsCollector) TrackDMARCDuration(duration time.Duration) {} func (m *mockMetricsCollector) TrackSPFResultByDomain(domain, result string) {} func (m *mockMetricsCollector) TrackDKIMResultByDomain(domain, result string) {}