diff --git a/backend/dto/parsemail/dkim.go b/backend/dto/parsemail/dkim.go index dae5913..bc2a66d 100644 --- a/backend/dto/parsemail/dkim.go +++ b/backend/dto/parsemail/dkim.go @@ -120,23 +120,33 @@ func (p *Dkim) Sign(msgData string) ([]byte, error) { } func Check(ctx *context.Context, mail io.Reader) bool { + return CheckForDomain(ctx, mail, "") +} - verifications, err := dkim.Verify(mail) +// CheckForDomain 在指定域名时要求有效签名与发件域严格一致,避免其他域签名冒用本域。 +func CheckForDomain(ctx *context.Context, mail io.Reader, domain string) bool { + return checkForDomain(ctx, mail, domain, nil) +} + +func checkForDomain(ctx *context.Context, mail io.Reader, domain string, options *dkim.VerifyOptions) bool { + verifications, err := dkim.VerifyWithOptions(mail, options) if err != nil { log.WithContext(ctx).Warnf("DKIM Error:%v", err) - } - - if len(verifications) == 0 { return false } - + domain = strings.TrimSpace(domain) for _, v := range verifications { + if v == nil { + continue + } if v.Err == nil { log.Println("Valid signature for:", v.Domain) + if domain == "" || strings.EqualFold(v.Domain, domain) { + return true + } } else { log.Println("Invalid signature for:", v.Domain, v.Err) - return false } } - return true + return false } diff --git a/backend/dto/parsemail/dkim_security_test.go b/backend/dto/parsemail/dkim_security_test.go index 42ffae3..f4e5401 100644 --- a/backend/dto/parsemail/dkim_security_test.go +++ b/backend/dto/parsemail/dkim_security_test.go @@ -1,19 +1,70 @@ package parsemail import ( + "bytes" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" + "encoding/base64" "encoding/pem" "os" "path/filepath" + "strings" "testing" "git.pcux.cn/xuans/Smail/config" + "github.com/emersion/go-msgauth/dkim" ) +func TestCheckForDomainRequiresValidAlignedSignature(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + publicKey, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + options := &dkim.VerifyOptions{LookupTXT: func(domain string) ([]string, error) { + return []string{"v=DKIM1; k=rsa; p=" + base64.StdEncoding.EncodeToString(publicKey)}, nil + }} + message := "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: authentication\r\n\r\noriginal body\r\n" + for _, tc := range []struct { + name, signer, expected string + tampered bool + want bool + }{ + {name: "aligned", signer: "example.com", expected: "example.com", want: true}, + {name: "case_insensitive", signer: "example.com", expected: "EXAMPLE.COM", want: true}, + {name: "foreign_domain", signer: "other.example", expected: "example.com"}, + {name: "subdomain", signer: "sub.example.com", expected: "example.com"}, + {name: "suffix_attack", signer: "example.com.other.example", expected: "example.com"}, + {name: "invalid_signature", signer: "example.com", expected: "example.com", tampered: true}, + {name: "generic_verification", signer: "other.example", want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + var signed bytes.Buffer + if err := dkim.Sign(&signed, strings.NewReader(message), &dkim.SignOptions{ + Domain: tc.signer, Selector: "default", Signer: key, + }); err != nil { + t.Fatal(err) + } + data := signed.String() + if tc.tampered { + data += "tampered body\r\n" + } + if got := checkForDomain(nil, strings.NewReader(data), tc.expected, options); got != tc.want { + t.Fatalf("签名认证结果为 %v,期望 %v", got, tc.want) + } + }) + } + if checkForDomain(nil, strings.NewReader(message), "example.com", options) { + t.Fatal("未签名邮件不能通过认证") + } +} + func TestLoadPrivateKeyAcceptsStrongRSAKey(t *testing.T) { path := writeDKIMTestKey(t, 2048) signer, err := loadPrivateKey(path) diff --git a/backend/listen/smtp_server/read_content.go b/backend/listen/smtp_server/read_content.go index 95318e4..76d24cf 100644 --- a/backend/listen/smtp_server/read_content.go +++ b/backend/listen/smtp_server/read_content.go @@ -192,17 +192,20 @@ func (s *Session) Data(r io.Reader) (resultErr error) { var dkimStatus, SPFStatus bool details.Stage = "验证收信身份与插件处理" + _, fromDomain := email.From.GetDomainAccount() + domains := config.CurrentMailDomains() + localSender := array.InArray(fromDomain, domains.Accepted) + dkimDomain := "" + if localSender { + dkimDomain = fromDomain + } // DKIM校验 - dkimStatus = parsemail.Check(ctx, bytes.NewReader(emailData)) + dkimStatus = parsemail.CheckForDomain(ctx, bytes.NewReader(emailData), dkimDomain) SPFStatus = spfCheck(s.RemoteAddress.String(), email.Sender, email.Sender.EmailAddress) - _, formDomain := email.From.GetDomainAccount() - domains := config.CurrentMailDomains() - spoofed := array.InArray(formDomain, domains.Accepted) && SPFStatus == false - if spoofed { - dkimStatus = false - } + // 本机投递也保留真实 SPF 结果,仅有效且同域的 DKIM 签名可以避免误判伪造。 + spoofed := localSender && !SPFStatus && !dkimStatus email.Authentication = parsemail.NewEmailAuthentication(SPFStatus, dkimStatus) log.WithContext(ctx).Debugf("开始执行插件ReceiveParseAfter!")