diff --git a/pkg/analyzer/dns.go b/pkg/analyzer/dns.go index 3098934..10babb0 100644 --- a/pkg/analyzer/dns.go +++ b/pkg/analyzer/dns.go @@ -54,7 +54,7 @@ func NewDNSAnalyzerWithResolver(timeout time.Duration, resolver DNSResolver) *DN } // AnalyzeDNS performs DNS validation for the email's domain -func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.AuthenticationResults, headersResults *api.HeaderAnalysis) *api.DNSResults { +func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *api.HeaderAnalysis) *api.DNSResults { // Extract domain from From address if headersResults.DomainAlignment.FromDomain == nil || *headersResults.DomainAlignment.FromDomain == "" { return &api.DNSResults{ @@ -104,19 +104,14 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, authResults *api.Authentic // SPF validates the MAIL FROM command, which corresponds to Return-Path results.SpfRecords = d.checkSPFRecords(spfDomain) - // Check DKIM records (from authentication results) - // DKIM can be for any domain, but typically the From domain - if authResults != nil && authResults.Dkim != nil { - for _, dkim := range *authResults.Dkim { - if dkim.Domain != nil && dkim.Selector != nil { - dkimRecord := d.checkDKIMRecord(*dkim.Domain, *dkim.Selector) - if dkimRecord != nil { - if results.DkimRecords == nil { - results.DkimRecords = new([]api.DKIMRecord) - } - *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) - } + // Check DKIM records by parsing DKIM-Signature headers directly + for _, sig := range parseDKIMSignatures(email.Header["DKIM-Signature"]) { + dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector) + if dkimRecord != nil { + if results.DkimRecords == nil { + results.DkimRecords = new([]api.DKIMRecord) } + *results.DkimRecords = append(*results.DkimRecords, *dkimRecord) } } diff --git a/pkg/analyzer/dns_dkim.go b/pkg/analyzer/dns_dkim.go index 7ac858d..1a8a199 100644 --- a/pkg/analyzer/dns_dkim.go +++ b/pkg/analyzer/dns_dkim.go @@ -29,6 +29,38 @@ import ( "git.happydns.org/happyDeliver/internal/api" ) +// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header. +type DKIMHeader struct { + Domain string + Selector string +} + +// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values. +func parseDKIMSignatures(signatures []string) []DKIMHeader { + var results []DKIMHeader + for _, sig := range signatures { + var domain, selector string + for _, part := range strings.Split(sig, ";") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + continue + } + key := strings.TrimSpace(kv[0]) + val := strings.TrimSpace(kv[1]) + switch key { + case "d": + domain = val + case "s": + selector = val + } + } + if domain != "" && selector != "" { + results = append(results, DKIMHeader{Domain: domain, Selector: selector}) + } + } + return results +} + // checkapi.DKIMRecord looks up and validates DKIM record for a domain and selector func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *api.DKIMRecord { // DKIM records are at: selector._domainkey.domain diff --git a/pkg/analyzer/dns_dkim_test.go b/pkg/analyzer/dns_dkim_test.go index 8d94d20..45da53c 100644 --- a/pkg/analyzer/dns_dkim_test.go +++ b/pkg/analyzer/dns_dkim_test.go @@ -26,6 +26,220 @@ import ( "time" ) +func TestParseDKIMSignatures(t *testing.T) { + tests := []struct { + name string + signatures []string + expected []DKIMHeader + }{ + { + name: "Empty input", + signatures: nil, + expected: nil, + }, + { + name: "Empty string", + signatures: []string{""}, + expected: nil, + }, + { + name: "Simple Gmail-style", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=gmail.com; s=20210112; h=from:to:subject:date:message-id; bh=abcdef1234567890=; b=SIGNATURE_DATA_HERE==`, + }, + expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112"}}, + }, + { + name: "Microsoft 365 style", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=contoso.com; s=selector1; h=From:Date:Subject:Message-ID; bh=UErATeHehIIPIXPeUA==; b=SIGNATURE_DATA==`, + }, + expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1"}}, + }, + { + name: "Tab-folded multiline (Postfix-style)", + signatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/simple; d=nemunai.re; s=thot;\r\n\tt=1760866834; bh=YNB7c8Qgm8YGn9X1FAXTcdpO7t4YSZFiMrmpCfD/3zw=;\r\n\th=From:To:Subject;\r\n\tb=T4TFaypMpsHGYCl3PGLwmzOYRF11rYjC7lF8V5VFU+ldvG8WBpFn==", + }, + expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot"}}, + }, + { + name: "Space-folded multiline (RFC-style)", + signatures: []string{ + "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8Gwps==", + }, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test"}}, + }, + { + name: "d= and s= on separate continuation lines", + signatures: []string{ + "v=1; a=rsa-sha256;\r\n\tc=relaxed/relaxed;\r\n\td=mycompany.com;\r\n\ts=selector1;\r\n\tbh=hash=;\r\n\tb=sig==", + }, + expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1"}}, + }, + { + name: "No space after semicolons", + signatures: []string{ + `v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`, + }, + expected: []DKIMHeader{{Domain: "example.net", Selector: "mail"}}, + }, + { + name: "Multiple spaces after semicolons", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}}, + }, + { + name: "Ed25519 signature (RFC 8463)", + signatures: []string{ + "v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from:to:subject;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQ==", + }, + expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane"}}, + }, + { + name: "Multiple signatures (ESP double-signing)", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mydomain.com; s=mail; h=from:to:subject; bh=hash1=; b=sig1==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`, + }, + expected: []DKIMHeader{ + {Domain: "mydomain.com", Selector: "mail"}, + {Domain: "sendib.com", Selector: "mail"}, + }, + }, + { + name: "Dual-algorithm signing (Ed25519 + RSA, same domain, different selectors)", + signatures: []string{ + `v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; s=brisbane; h=from:to:subject; bh=hash=; b=edSig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`, + }, + expected: []DKIMHeader{ + {Domain: "football.example.com", Selector: "brisbane"}, + {Domain: "football.example.com", Selector: "test"}, + }, + }, + { + name: "Amazon SES long selectors", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/simple; d=amazonses.com; s=224i4yxa5dv7c2xz3womw6peuabd; h=from:to:subject; bh=sesHash=; b=sesSig==`, + `v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`, + }, + expected: []DKIMHeader{ + {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"}, + {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"}, + }, + }, + { + name: "Subdomain in d=", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=mail.example.co.uk; s=dkim2025; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025"}}, + }, + { + name: "Deeply nested subdomain", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=bounce.transactional.mail.example.com; s=s2048; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048"}}, + }, + { + name: "Selector with hyphens (Microsoft 365 custom domain style)", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=selector1-contoso-com; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com"}}, + }, + { + name: "Selector with dots", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail"}}, + }, + { + name: "Single-character selector", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x"}}, + }, + { + name: "Postmark-style timestamp selector, s= before d=", + signatures: []string{ + `v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`, + }, + expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm"}}, + }, + { + name: "d= and s= at the very end", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`, + }, + expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing"}}, + }, + { + name: "Full tag set", + signatures: []string{ + `v=1; a=rsa-sha256; d=example.com; s=selector1; c=relaxed/simple; q=dns/txt; i=user@example.com; t=1255993973; x=1256598773; h=From:Sender:Reply-To:Subject:Date:Message-Id:To:Cc; bh=+7qxGePcmmrtZAIVQAtkSSGHfQ/ftNuvUTWJ3vXC9Zc=; b=dB85+qM+If1KGQmqMLNpqLgNtUaG5dhGjYjQD6/QXtXmViJx8tf9gLEjcHr+musLCAvr0Fsn1DA3ZLLlUxpf4AR==`, + }, + expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1"}}, + }, + { + name: "Missing d= tag", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; s=selector1; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Missing s= tag", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Missing both d= and s= tags", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to; bh=hash=; b=sig==`, + }, + expected: nil, + }, + { + name: "Mix of valid and invalid signatures", + signatures: []string{ + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=good.com; s=sel1; h=from:to; bh=hash=; b=sig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; s=orphan; h=from:to; bh=hash=; b=sig==`, + `v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; h=from:to; bh=hash=; b=sig==`, + }, + expected: []DKIMHeader{ + {Domain: "good.com", Selector: "sel1"}, + {Domain: "also-good.com", Selector: "sel2"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseDKIMSignatures(tt.signatures) + if len(result) != len(tt.expected) { + t.Fatalf("parseDKIMSignatures() returned %d results, want %d\n got: %+v\n want: %+v", len(result), len(tt.expected), result, tt.expected) + } + for i := range tt.expected { + if result[i].Domain != tt.expected[i].Domain { + t.Errorf("result[%d].Domain = %q, want %q", i, result[i].Domain, tt.expected[i].Domain) + } + if result[i].Selector != tt.expected[i].Selector { + t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector) + } + } + }) + } +} + func TestValidateDKIM(t *testing.T) { tests := []struct { name string diff --git a/pkg/analyzer/report.go b/pkg/analyzer/report.go index bd12960..354f911 100644 --- a/pkg/analyzer/report.go +++ b/pkg/analyzer/report.go @@ -83,7 +83,7 @@ func (r *ReportGenerator) AnalyzeEmail(email *EmailMessage) *AnalysisResults { // Run all analyzers results.Authentication = r.authAnalyzer.AnalyzeAuthentication(email) results.Headers = r.headerAnalyzer.GenerateHeaderAnalysis(email, results.Authentication) - results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Authentication, results.Headers) + results.DNS = r.dnsAnalyzer.AnalyzeDNS(email, results.Headers) results.RBL = r.rblChecker.CheckEmail(email) results.DNSWL = r.dnswlChecker.CheckEmail(email) results.SpamAssassin = r.spamAnalyzer.AnalyzeSpamAssassin(email)