dkim: expose algorithm, hash list, and key size in DKIM record analysis

Parse k=, h=, a= tags and derive RSA key bit-length from the public key
so consumers can detect weak configurations (SHA-1, short keys).
Scoring now penalises rsa-sha1 (cap 60), RSA <1024 bit (cap 25), and
RSA <2048 bit (cap 75); Ed25519 receives no penalty.

Fixes: #37
This commit is contained in:
nemunaire 2026-05-18 17:15:48 +08:00
commit 1b8627ef86
4 changed files with 321 additions and 69 deletions

View file

@ -873,6 +873,24 @@ components:
type: string type: string
description: DKIM record content description: DKIM record content
example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..." example: "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..."
key_type:
type: string
description: "Key type from k= tag (e.g. rsa, ed25519); defaults to rsa if absent"
example: "rsa"
hash_algorithms:
type: array
items:
type: string
description: "Acceptable hash algorithms from h= tag; empty means all accepted (RFC 6376 default: sha256)"
example: ["sha256"]
signing_algorithm:
type: string
description: "Algorithm used in DKIM-Signature a= tag (e.g. rsa-sha256, ed25519-sha256)"
example: "rsa-sha256"
key_size:
type: integer
description: "Public key size in bits (RSA: 1024/2048/4096; Ed25519: always 256)"
example: 2048
valid: valid:
type: boolean type: boolean
description: Whether the DKIM record is valid description: Whether the DKIM record is valid

View file

@ -106,7 +106,7 @@ func (d *DNSAnalyzer) AnalyzeDNS(email *EmailMessage, headersResults *model.Head
// Check DKIM records by parsing DKIM-Signature headers directly // Check DKIM records by parsing DKIM-Signature headers directly
for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) { for _, sig := range parseDKIMSignatures(email.Header["Dkim-Signature"]) {
dkimRecord := d.checkDKIMRecord(sig.Domain, sig.Selector) dkimRecord := d.checkDKIMRecord(sig)
if dkimRecord != nil { if dkimRecord != nil {
if results.DkimRecords == nil { if results.DkimRecords == nil {
results.DkimRecords = new([]model.DKIMRecord) results.DkimRecords = new([]model.DKIMRecord)

View file

@ -23,6 +23,8 @@ package analyzer
import ( import (
"context" "context"
"crypto/x509"
"encoding/base64"
"fmt" "fmt"
"strings" "strings"
@ -30,17 +32,18 @@ import (
"git.happydns.org/happyDeliver/internal/utils" "git.happydns.org/happyDeliver/internal/utils"
) )
// DKIMHeader holds the domain and selector extracted from a DKIM-Signature header. // DKIMHeader holds the domain, selector and signing algorithm from a DKIM-Signature header.
type DKIMHeader struct { type DKIMHeader struct {
Domain string Domain string
Selector string Selector string
Algorithm string // from a= tag (e.g. rsa-sha256, ed25519-sha256)
} }
// parseDKIMSignatures extracts domain and selector from DKIM-Signature header values. // parseDKIMSignatures extracts domain, selector and algorithm from DKIM-Signature header values.
func parseDKIMSignatures(signatures []string) []DKIMHeader { func parseDKIMSignatures(signatures []string) []DKIMHeader {
var results []DKIMHeader var results []DKIMHeader
for _, sig := range signatures { for _, sig := range signatures {
var domain, selector string var domain, selector, algorithm string
for _, part := range strings.Split(sig, ";") { for _, part := range strings.Split(sig, ";") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2) kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 { if len(kv) != 2 {
@ -53,19 +56,61 @@ func parseDKIMSignatures(signatures []string) []DKIMHeader {
domain = val domain = val
case "s": case "s":
selector = val selector = val
case "a":
algorithm = val
} }
} }
if domain != "" && selector != "" { if domain != "" && selector != "" {
results = append(results, DKIMHeader{Domain: domain, Selector: selector}) results = append(results, DKIMHeader{Domain: domain, Selector: selector, Algorithm: algorithm})
} }
} }
return results return results
} }
// checkmodel.DKIMRecord looks up and validates DKIM record for a domain and selector // parseDKIMTags splits a DKIM DNS record into a tag→value map.
func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord { func parseDKIMTags(record string) map[string]string {
// DKIM records are at: selector._domainkey.domain tags := make(map[string]string)
dkimDomain := fmt.Sprintf("%s._domainkey.%s", selector, domain) for _, part := range strings.Split(record, ";") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
tags[strings.TrimSpace(kv[0])] = strings.TrimSpace(kv[1])
}
return tags
}
// parseKeySize derives the public key bit length from a base64-encoded DER public key.
// For RSA keys it parses the PKIX structure; for Ed25519 it always returns 256.
func parseKeySize(keyType, p string) *int {
switch strings.ToLower(keyType) {
case "ed25519":
return utils.PtrTo(256)
case "rsa", "":
der, err := base64.StdEncoding.DecodeString(p)
if err != nil {
// Try without padding
der, err = base64.RawStdEncoding.DecodeString(p)
if err != nil {
return nil
}
}
pub, err := x509.ParsePKIXPublicKey(der)
if err != nil {
return nil
}
if rsaPub, ok := pub.(interface{ Size() int }); ok {
bits := rsaPub.Size() * 8
return &bits
}
return nil
}
return nil
}
// checkDKIMRecord looks up and validates DKIM record for a domain and selector.
func (d *DNSAnalyzer) checkDKIMRecord(h DKIMHeader) *model.DKIMRecord {
dkimDomain := fmt.Sprintf("%s._domainkey.%s", h.Selector, h.Domain)
ctx, cancel := context.WithTimeout(context.Background(), d.Timeout) ctx, cancel := context.WithTimeout(context.Background(), d.Timeout)
defer cancel() defer cancel()
@ -73,53 +118,83 @@ func (d *DNSAnalyzer) checkDKIMRecord(domain, selector string) *model.DKIMRecord
txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain) txtRecords, err := d.resolver.LookupTXT(ctx, dkimDomain)
if err != nil { if err != nil {
return &model.DKIMRecord{ return &model.DKIMRecord{
Selector: selector, Selector: h.Selector,
Domain: domain, Domain: h.Domain,
Valid: false, SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)), Valid: false,
Error: utils.PtrTo(fmt.Sprintf("Failed to lookup DKIM record: %v", err)),
} }
} }
if len(txtRecords) == 0 { if len(txtRecords) == 0 {
return &model.DKIMRecord{ return &model.DKIMRecord{
Selector: selector, Selector: h.Selector,
Domain: domain, Domain: h.Domain,
Valid: false, SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
Error: utils.PtrTo("No DKIM record found"), Valid: false,
Error: utils.PtrTo("No DKIM record found"),
} }
} }
// Concatenate all TXT record parts (DKIM can be split) // Concatenate all TXT record parts (DKIM can be split)
dkimRecord := strings.Join(txtRecords, "") dkimRecord := strings.Join(txtRecords, "")
// Basic validation - should contain "v=DKIM1" and "p=" (public key)
if !d.validateDKIM(dkimRecord) { if !d.validateDKIM(dkimRecord) {
return &model.DKIMRecord{ return &model.DKIMRecord{
Selector: selector, Selector: h.Selector,
Domain: domain, Domain: h.Domain,
Record: utils.PtrTo(dkimRecord), Record: utils.PtrTo(dkimRecord),
Valid: false, SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
Error: utils.PtrTo("DKIM record appears malformed"), Valid: false,
Error: utils.PtrTo("DKIM record appears malformed"),
} }
} }
tags := parseDKIMTags(dkimRecord)
keyType := tags["k"]
if keyType == "" {
keyType = "rsa" // RFC 6376 default
}
var hashAlgorithms []string
if h, ok := tags["h"]; ok && h != "" {
for _, alg := range strings.Split(h, ":") {
if a := strings.TrimSpace(alg); a != "" {
hashAlgorithms = append(hashAlgorithms, a)
}
}
}
if hashAlgorithms == nil {
hashAlgorithms = []string{}
}
return &model.DKIMRecord{ return &model.DKIMRecord{
Selector: selector, Selector: h.Selector,
Domain: domain, Domain: h.Domain,
Record: &dkimRecord, Record: &dkimRecord,
Valid: true, KeyType: utils.PtrTo(keyType),
HashAlgorithms: &hashAlgorithms,
SigningAlgorithm: signingAlgorithmPtr(h.Algorithm),
KeySize: parseKeySize(keyType, tags["p"]),
Valid: true,
} }
} }
// validateDKIM performs basic DKIM record validation func signingAlgorithmPtr(a string) *string {
if a == "" {
return nil
}
return &a
}
// validateDKIM performs basic DKIM record validation.
func (d *DNSAnalyzer) validateDKIM(record string) bool { func (d *DNSAnalyzer) validateDKIM(record string) bool {
// Should contain p= tag (public key)
if !strings.Contains(record, "p=") { if !strings.Contains(record, "p=") {
return false return false
} }
// Often contains v=DKIM1 but not required // If v= is present, it must be DKIM1
// If v= is present, it should be DKIM1
if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") { if strings.Contains(record, "v=") && !strings.Contains(record, "v=DKIM1") {
return false return false
} }
@ -128,21 +203,57 @@ func (d *DNSAnalyzer) validateDKIM(record string) bool {
} }
func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) { func (d *DNSAnalyzer) calculateDKIMScore(results *model.DNSResults) (score int) {
// DKIM provides strong email authentication if results.DkimRecords == nil || len(*results.DkimRecords) == 0 {
if results.DkimRecords != nil && len(*results.DkimRecords) > 0 { return 0
hasValidDKIM := false }
for _, dkim := range *results.DkimRecords {
if dkim.Valid { hasValid := false
hasValidDKIM = true for _, dkim := range *results.DkimRecords {
break if dkim.Valid {
hasValid = true
break
}
}
if !hasValid {
return 25
}
score = 100
// Apply security penalties on the best valid record
for _, dkim := range *results.DkimRecords {
if !dkim.Valid {
continue
}
// SHA-1 signing is deprecated (RFC 8301)
if dkim.SigningAlgorithm != nil && strings.HasSuffix(*dkim.SigningAlgorithm, "-sha1") {
if score > 60 {
score = 60
} }
} }
if hasValidDKIM {
score += 100 // Key size penalties apply only to RSA
} else { keyType := ""
// Partial credit if DKIM record exists but has issues if dkim.KeyType != nil {
score += 25 keyType = strings.ToLower(*dkim.KeyType)
} }
if keyType == "rsa" || keyType == "" {
if dkim.KeySize != nil {
switch {
case *dkim.KeySize < 1024:
if score > 25 {
score = 25
}
case *dkim.KeySize < 2048:
if score > 75 {
score = 75
}
}
}
}
// Ed25519 keys (256-bit curve, ~3000-bit RSA equivalent) need no penalty.
} }
return return

View file

@ -22,6 +22,10 @@
package analyzer package analyzer
import ( import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"testing" "testing"
"time" "time"
) )
@ -47,56 +51,56 @@ func TestParseDKIMSignatures(t *testing.T) {
signatures: []string{ 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==`, `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"}}, expected: []DKIMHeader{{Domain: "gmail.com", Selector: "20210112", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Microsoft 365 style", name: "Microsoft 365 style",
signatures: []string{ 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==`, `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"}}, expected: []DKIMHeader{{Domain: "contoso.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Tab-folded multiline (Postfix-style)", name: "Tab-folded multiline (Postfix-style)",
signatures: []string{ 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==", "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"}}, expected: []DKIMHeader{{Domain: "nemunai.re", Selector: "thot", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Space-folded multiline (RFC-style)", name: "Space-folded multiline (RFC-style)",
signatures: []string{ 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==", "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"}}, expected: []DKIMHeader{{Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "d= and s= on separate continuation lines", name: "d= and s= on separate continuation lines",
signatures: []string{ 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==", "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"}}, expected: []DKIMHeader{{Domain: "mycompany.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "No space after semicolons", name: "No space after semicolons",
signatures: []string{ signatures: []string{
`v=1;a=rsa-sha256;c=relaxed/relaxed;d=example.net;s=mail;h=from:to:subject;bh=abc=;b=xyz==`, `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"}}, expected: []DKIMHeader{{Domain: "example.net", Selector: "mail", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Multiple spaces after semicolons", name: "Multiple spaces after semicolons",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=myselector; bh=hash=; b=sig==`,
}, },
expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "myselector", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Ed25519 signature (RFC 8463)", name: "Ed25519 signature (RFC 8463)",
signatures: []string{ 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==", "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"}}, expected: []DKIMHeader{{Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"}},
}, },
{ {
name: "Multiple signatures (ESP double-signing)", name: "Multiple signatures (ESP double-signing)",
@ -105,8 +109,8 @@ func TestParseDKIMSignatures(t *testing.T) {
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=sendib.com; s=mail; h=from:to:subject; bh=hash1=; b=sig2==`,
}, },
expected: []DKIMHeader{ expected: []DKIMHeader{
{Domain: "mydomain.com", Selector: "mail"}, {Domain: "mydomain.com", Selector: "mail", Algorithm: "rsa-sha256"},
{Domain: "sendib.com", Selector: "mail"}, {Domain: "sendib.com", Selector: "mail", Algorithm: "rsa-sha256"},
}, },
}, },
{ {
@ -116,8 +120,8 @@ func TestParseDKIMSignatures(t *testing.T) {
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`, `v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; s=test; h=from:to:subject; bh=hash=; b=rsaSig==`,
}, },
expected: []DKIMHeader{ expected: []DKIMHeader{
{Domain: "football.example.com", Selector: "brisbane"}, {Domain: "football.example.com", Selector: "brisbane", Algorithm: "ed25519-sha256"},
{Domain: "football.example.com", Selector: "test"}, {Domain: "football.example.com", Selector: "test", Algorithm: "rsa-sha256"},
}, },
}, },
{ {
@ -127,8 +131,8 @@ func TestParseDKIMSignatures(t *testing.T) {
`v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`, `v=1; a=rsa-sha256; c=relaxed/simple; d=customerdomain.io; s=ug7nbtf4gccmlpwj322ax3p6ow6fovbt; h=from:to:subject; bh=sesHash=; b=customSig==`,
}, },
expected: []DKIMHeader{ expected: []DKIMHeader{
{Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd"}, {Domain: "amazonses.com", Selector: "224i4yxa5dv7c2xz3womw6peuabd", Algorithm: "rsa-sha256"},
{Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt"}, {Domain: "customerdomain.io", Selector: "ug7nbtf4gccmlpwj322ax3p6ow6fovbt", Algorithm: "rsa-sha256"},
}, },
}, },
{ {
@ -136,56 +140,56 @@ func TestParseDKIMSignatures(t *testing.T) {
signatures: []string{ 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==`, `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"}}, expected: []DKIMHeader{{Domain: "mail.example.co.uk", Selector: "dkim2025", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Deeply nested subdomain", name: "Deeply nested subdomain",
signatures: []string{ 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==`, `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"}}, expected: []DKIMHeader{{Domain: "bounce.transactional.mail.example.com", Selector: "s2048", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Selector with hyphens (Microsoft 365 custom domain style)", name: "Selector with hyphens (Microsoft 365 custom domain style)",
signatures: []string{ 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==`, `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"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1-contoso-com", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Selector with dots", name: "Selector with dots",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=example.com; s=smtp.mail; h=from:to:subject; bh=hash=; b=sig==`, `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"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "smtp.mail", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Single-character selector", name: "Single-character selector",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=tiny.io; s=x; h=from:to:subject; bh=hash=; b=sig==`, `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"}}, expected: []DKIMHeader{{Domain: "tiny.io", Selector: "x", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Postmark-style timestamp selector, s= before d=", name: "Postmark-style timestamp selector, s= before d=",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha1; c=relaxed/relaxed; s=20130519032151pm; d=postmarkapp.com; h=From:Date:Subject; bh=vYFvy46eesUDGJ45hyBTH30JfN4=; b=iHeFQ+7rCiSQs3DPjR2eUSZSv4i==`, `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"}}, expected: []DKIMHeader{{Domain: "postmarkapp.com", Selector: "20130519032151pm", Algorithm: "rsa-sha1"}},
}, },
{ {
name: "d= and s= at the very end", name: "d= and s= at the very end",
signatures: []string{ signatures: []string{
`v=1; a=rsa-sha256; c=relaxed/relaxed; h=from:to:subject; bh=hash=; b=sig==; d=example.net; s=trailing`, `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"}}, expected: []DKIMHeader{{Domain: "example.net", Selector: "trailing", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Full tag set", name: "Full tag set",
signatures: []string{ 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==`, `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"}}, expected: []DKIMHeader{{Domain: "example.com", Selector: "selector1", Algorithm: "rsa-sha256"}},
}, },
{ {
name: "Missing d= tag", name: "Missing d= tag",
@ -216,8 +220,8 @@ func TestParseDKIMSignatures(t *testing.T) {
`v=1; a=rsa-sha256; c=relaxed/relaxed; d=also-good.com; s=sel2; 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{ expected: []DKIMHeader{
{Domain: "good.com", Selector: "sel1"}, {Domain: "good.com", Selector: "sel1", Algorithm: "rsa-sha256"},
{Domain: "also-good.com", Selector: "sel2"}, {Domain: "also-good.com", Selector: "sel2", Algorithm: "rsa-sha256"},
}, },
}, },
} }
@ -235,6 +239,9 @@ func TestParseDKIMSignatures(t *testing.T) {
if result[i].Selector != tt.expected[i].Selector { if result[i].Selector != tt.expected[i].Selector {
t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector) t.Errorf("result[%d].Selector = %q, want %q", i, result[i].Selector, tt.expected[i].Selector)
} }
if result[i].Algorithm != tt.expected[i].Algorithm {
t.Errorf("result[%d].Algorithm = %q, want %q", i, result[i].Algorithm, tt.expected[i].Algorithm)
}
} }
}) })
} }
@ -284,3 +291,119 @@ func TestValidateDKIM(t *testing.T) {
}) })
} }
} }
func TestParseDKIMTags(t *testing.T) {
tests := []struct {
name string
record string
wantTags map[string]string
}{
{
name: "standard RSA record",
record: "v=DKIM1; k=rsa; p=MIIBI; h=sha256",
wantTags: map[string]string{"v": "DKIM1", "k": "rsa", "p": "MIIBI", "h": "sha256"},
},
{
name: "ed25519 record",
record: "v=DKIM1; k=ed25519; p=11qYAYKxCrfVS",
wantTags: map[string]string{"v": "DKIM1", "k": "ed25519", "p": "11qYAYKxCrfVS"},
},
{
name: "missing k= defaults",
record: "v=DKIM1; p=MIIBI",
wantTags: map[string]string{"v": "DKIM1", "p": "MIIBI"},
},
{
name: "empty record",
record: "",
wantTags: map[string]string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseDKIMTags(tt.record)
for key, want := range tt.wantTags {
if got[key] != want {
t.Errorf("tag %q = %q, want %q", key, got[key], want)
}
}
})
}
}
func TestParseKeySize(t *testing.T) {
// Generate a real RSA key for testing
rsaKey1024, _ := rsa.GenerateKey(rand.Reader, 1024)
rsaKey2048, _ := rsa.GenerateKey(rand.Reader, 2048)
der1024, _ := x509.MarshalPKIXPublicKey(&rsaKey1024.PublicKey)
der2048, _ := x509.MarshalPKIXPublicKey(&rsaKey2048.PublicKey)
p1024 := base64.StdEncoding.EncodeToString(der1024)
p2048 := base64.StdEncoding.EncodeToString(der2048)
tests := []struct {
name string
keyType string
p string
want *int
}{
{
name: "RSA 1024",
keyType: "rsa",
p: p1024,
want: intPtr(1024),
},
{
name: "RSA 2048",
keyType: "rsa",
p: p2048,
want: intPtr(2048),
},
{
name: "Ed25519 always 256",
keyType: "ed25519",
p: "11qYAYKxCrfVS",
want: intPtr(256),
},
{
name: "Unknown key type",
keyType: "unknown",
p: "somedata",
want: nil,
},
{
name: "Invalid RSA base64",
keyType: "rsa",
p: "!!!not-base64!!!",
want: nil,
},
{
name: "Empty k= defaults to RSA",
keyType: "",
p: p2048,
want: intPtr(2048),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseKeySize(tt.keyType, tt.p)
if tt.want == nil {
if got != nil {
t.Errorf("parseKeySize(%q, ...) = %d, want nil", tt.keyType, *got)
}
return
}
if got == nil {
t.Fatalf("parseKeySize(%q, ...) = nil, want %d", tt.keyType, *tt.want)
}
if *got != *tt.want {
t.Errorf("parseKeySize(%q, ...) = %d, want %d", tt.keyType, *got, *tt.want)
}
})
}
}
func intPtr(v int) *int { return &v }