Parse DKIM-Signature headers directly in AnalyzeDNS
Remove authResults parameter from AnalyzeDNS, making it independent of the authentication analysis step. Instead, parse DKIM-Signature headers directly to extract domain and selector. Bug: https://github.com/happyDomain/happydeliver/issues/11
This commit is contained in:
parent
c96a8b92b8
commit
71e0832416
4 changed files with 255 additions and 14 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue