Commit a13f124bf6

a13f124bf66482827f0f6f8bd78303d7720489e6

parent: 5c4e2fad20

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-29 15:17 UTC

mailin: verify DKIM on replies with [mail.inbound] require_dkim

A reply must carry a DKIM signature, verified by gitbayd with
github.com/emersion/go-msgauth, that covers From and whose d= is in
relaxed alignment with the From domain. rsa-sha256 (1024 bits or more)
and ed25519-sha256; l=, rsa-sha1, expired x= and future t= refused;
first five signatures only; selector keys looked up with a 5s timeout
and cached for an hour; a temporary DNS failure retries. Either this or
trusted_authserv_id passing is enough when both are set.

Ref #307

Layout: unified · split

cmd/gitbayd/main.go +2 −2
@@ -213,8 +213,8 @@ func serveCmd() *cobra.Command {
213213 if _, err := in.Password(); err != nil {
214214 return err
215215 }
216 if in.TrustedAuthservID == "" {
217 slog.Warn("mail reply: [mail.inbound] trusted_authserv_id is unset, so a reply's From is not checked against the mail host's DMARC and DKIM results; set it on any instance reachable from the internet")
216 if !in.Authenticated() {
217 slog.Warn("mail reply: [mail.inbound] require_dkim and trusted_authserv_id are unset, so a reply's From is not authenticated; set one on any instance reachable from the internet")
218218 }
219219 go (&mailin.Poller{P: &mailin.Processor{St: st, Cfg: cfg}, In: in}).Run(whCtx)
220220 }
deploy/audit.sh +1
@@ -18,6 +18,7 @@ go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/
1818go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/
1919go test -run xxx -fuzz FuzzReply -fuzztime 10s ./internal/mailin/
2020go test -run xxx -fuzz FuzzAuthResults -fuzztime 10s ./internal/mailin/
21go test -run xxx -fuzz FuzzDKIM -fuzztime 10s ./internal/mailin/
2122go test -run xxx -fuzz FuzzReadResponse -fuzztime 10s ./internal/imapc/
2223
2324echo "== all clear =="
go.mod +1
@@ -7,6 +7,7 @@ require (
77 github.com/BurntSushi/toml v1.6.0
88 github.com/ProtonMail/go-crypto v1.5.2
99 github.com/alecthomas/chroma/v2 v2.27.0
10 github.com/emersion/go-msgauth v0.7.0
1011 github.com/microcosm-cc/bluemonday v1.0.27
1112 github.com/niklasfasching/go-org v1.9.1
1213 github.com/spf13/cobra v1.10.2
go.sum +2
@@ -30,6 +30,8 @@ github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee
3030github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
3131github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
3232github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
33github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
34github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
3335github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
3436github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
3537github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
internal/config/config.go +10
@@ -302,6 +302,16 @@ type MailInbound struct {
302302 // pass, or an aligned DKIM pass, in the topmost such header. Only
303303 // safe when the mail host removes incoming headers claiming its id.
304304 TrustedAuthservID string `toml:"trusted_authserv_id"`
305 // RequireDKIM makes a reply need a DKIM signature, verified by
306 // gitbayd, that covers From and whose d= is in relaxed alignment
307 // with the From domain. With TrustedAuthservID also set, either
308 // passing is enough.
309 RequireDKIM bool `toml:"require_dkim"`
310}
311
312// Authenticated reports whether a reply's From is checked at all.
313func (m MailInbound) Authenticated() bool {
314 return m.TrustedAuthservID != "" || m.RequireDKIM
305315}
306316
307317// DefaultInboundPoll is the poll interval when poll_interval is unset.
internal/config/config_test.go +7
@@ -423,6 +423,13 @@ func TestMailInbound(t *testing.T) {
423423 if in.Addr() != "imap.example:993" || in.MailboxName() != "INBOX" || in.Poll() != DefaultInboundPoll {
424424 t.Fatalf("defaults: %q %q %v", in.Addr(), in.MailboxName(), in.Poll())
425425 }
426 if in.RequireDKIM || in.Authenticated() {
427 t.Fatalf("require_dkim defaults on or From counts as authenticated: %+v", in)
428 }
429 cfg, err = Load(writeConfig(t, minimal+smtp+inbound+"require_dkim = true\n"))
430 if err != nil || !cfg.Mail.Inbound.RequireDKIM || !cfg.Mail.Inbound.Authenticated() {
431 t.Fatalf("require_dkim: %+v, %v", cfg.Mail.Inbound, err)
432 }
426433 in.TLS = "starttls"
427434 if in.Addr() != "imap.example:143" {
428435 t.Fatalf("starttls default port: %q", in.Addr())
internal/control/adminmail.go +11 −4
@@ -17,7 +17,7 @@ func init() {
1717 ReadOnly: true, Run: runAdminMailInboundCheck})
1818}
1919
20const unauthenticatedWarning = "trusted_authserv_id is unset: a reply's From is not checked against the mail host's DMARC and DKIM results"
20const unauthenticatedWarning = "require_dkim and trusted_authserv_id are unset: a reply's From is not authenticated"
2121
2222// runAdminMailInboundCheck logs in to the [mail.inbound] mailbox and
2323// opens it with EXAMINE, which changes no flag, so a check never marks a
@@ -36,7 +36,11 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
3636 Mailbox string `json:"mailbox,omitempty"`
3737 Messages int `json:"messages"`
3838 Unseen int `json:"unseen"`
39 Warning string `json:"warning,omitempty"`
39 // RequireDKIM and TrustedAuthservID are how a reply's From
40 // is authenticated.
41 RequireDKIM bool `json:"require_dkim"`
42 TrustedAuthservID string `json:"trusted_authserv_id,omitempty"`
43 Warning string `json:"warning,omitempty"`
4044 }
4145 if !in.Enabled {
4246 return c.emit(out{}, func(w io.Writer) {
@@ -52,8 +56,9 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
5256 if err != nil {
5357 return c.fail(protocol.ExitFailure, "%s: %v", in.Addr(), err)
5458 }
55 d := out{Enabled: true, Server: in.Addr(), Mailbox: in.MailboxName(), Messages: n, Unseen: len(unseen)}
56 if in.TrustedAuthservID == "" {
59 d := out{Enabled: true, Server: in.Addr(), Mailbox: in.MailboxName(), Messages: n, Unseen: len(unseen),
60 RequireDKIM: in.RequireDKIM, TrustedAuthservID: in.TrustedAuthservID}
61 if !in.Authenticated() {
5762 d.Warning = unauthenticatedWarning
5863 fmt.Fprintln(c.Stderr, "warning: "+d.Warning)
5964 }
@@ -63,6 +68,8 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
6368 "mailbox", d.Mailbox,
6469 "messages", fmt.Sprintf("%d", d.Messages),
6570 "unseen", fmt.Sprintf("%d", d.Unseen),
71 "require_dkim", fmt.Sprintf("%t", d.RequireDKIM),
72 "trusted_authserv_id", d.TrustedAuthservID,
6673 )
6774 })
6875}
internal/mailin/dkim.go added +164
@@ -0,0 +1,164 @@
1package mailin
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "net"
8 "net/mail"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/emersion/go-msgauth/dkim"
14)
15
16const (
17 // maxSignatures is how many DKIM-Signature fields are checked; any
18 // after them are ignored.
19 maxSignatures = 5
20 // dnsTimeout bounds one selector key lookup.
21 dnsTimeout = 5 * time.Second
22 // futureSkew is how far ahead of this clock a signature's t= may be.
23 futureSkew = 15 * time.Minute
24 keyCacheTTL = time.Hour
25 keyCacheSize = 256
26)
27
28// LookupTXT returns the TXT records at name, one string per record.
29type LookupTXT func(ctx context.Context, name string) ([]string, error)
30
31type cachedKey struct {
32 txts []string
33 expires time.Time
34}
35
36// keyCache holds selector key records that resolved, for keyCacheTTL,
37// at most keyCacheSize of them. Failures are not cached.
38type keyCache struct {
39 mu sync.Mutex
40 m map[string]cachedKey
41}
42
43func (c *keyCache) get(name string, now time.Time) ([]string, bool) {
44 c.mu.Lock()
45 defer c.mu.Unlock()
46 e, ok := c.m[name]
47 if !ok || now.After(e.expires) {
48 return nil, false
49 }
50 return e.txts, true
51}
52
53func (c *keyCache) put(name string, txts []string, now time.Time) {
54 c.mu.Lock()
55 defer c.mu.Unlock()
56 if c.m == nil {
57 c.m = map[string]cachedKey{}
58 }
59 if len(c.m) >= keyCacheSize {
60 for k, e := range c.m {
61 if now.After(e.expires) {
62 delete(c.m, k)
63 }
64 }
65 for k := range c.m {
66 if len(c.m) < keyCacheSize {
67 break
68 }
69 delete(c.m, k)
70 }
71 }
72 c.m[name] = cachedKey{txts: txts, expires: now.Add(keyCacheTTL)}
73}
74
75// lookupKey is the verifier's TXT lookup: cached, bounded by dnsTimeout.
76// The dkim package tells a temporary failure from a permanent one by
77// the error implementing net.Error with Temporary true, so every error
78// returned is a *net.DNSError, and one that is not a plain "no such
79// record" is marked temporary.
80func (p *Processor) lookupKey(name string) ([]string, error) {
81 now := p.now()
82 if txts, ok := p.keys.get(name, now); ok {
83 return txts, nil
84 }
85 lookup := p.LookupTXT
86 if lookup == nil {
87 lookup = net.DefaultResolver.LookupTXT
88 }
89 ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout)
90 defer cancel()
91 txts, err := lookup(ctx, name)
92 if err != nil {
93 var de *net.DNSError
94 if errors.As(err, &de) && de.IsNotFound {
95 return nil, &net.DNSError{Err: "no such record", Name: name, IsNotFound: true}
96 }
97 return nil, &net.DNSError{Err: "lookup failed", Name: name, IsTemporary: true}
98 }
99 p.keys.put(name, txts, now)
100 return txts, nil
101}
102
103// dkimVerified checks the DKIM signatures on raw, the message as it
104// was fetched. It returns "" when one of the first maxSignatures
105// verifies, covers From in h=, has a d= in relaxed alignment with the
106// From domain, has not expired and is not dated in the future. retry is
107// true when no signature passed and one could not be checked because
108// its key lookup failed for a reason that may pass.
109func (p *Processor) dkimVerified(raw []byte, h mail.Header, from string) (reason string, retry bool) {
110 _, fromDomain, ok := strings.Cut(strings.ToLower(from), "@")
111 if !ok || fromDomain == "" {
112 return "no From domain", false
113 }
114 // A second From field could be one the signature does not cover
115 // while it is the one read as the sender.
116 if len(h["From"]) != 1 {
117 return "more than one From field", false
118 }
119 verifs, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{
120 LookupTXT: p.lookupKey, MaxVerifications: maxSignatures})
121 if err != nil && !errors.Is(err, dkim.ErrTooManySignatures) {
122 return "DKIM: unreadable message", false
123 }
124 if len(verifs) == 0 {
125 return "no DKIM-Signature", false
126 }
127 now := p.now()
128 var fails []string
129 for _, v := range verifs {
130 d := strings.ToLower(v.Domain)
131 why := ""
132 switch {
133 case dkim.IsTempFail(v.Err):
134 retry = true
135 why = "key lookup failed"
136 case v.Err != nil:
137 why = strings.TrimPrefix(v.Err.Error(), "dkim: ")
138 case !signsFrom(v.HeaderKeys):
139 why = "From field not signed"
140 case !v.Expiration.IsZero() && now.After(v.Expiration):
141 why = "signature has expired"
142 case !v.Time.IsZero() && v.Time.After(now.Add(futureSkew)):
143 why = "signature dated in the future"
144 case !aligned(d, fromDomain):
145 why = "d= not aligned with the From domain"
146 default:
147 return "", false
148 }
149 if len(d) > 100 {
150 d = d[:100]
151 }
152 fails = append(fails, "d="+d+": "+why)
153 }
154 return "DKIM: no passing signature aligned with the From domain (" + strings.Join(fails, "; ") + ")", retry
155}
156
157func signsFrom(keys []string) bool {
158 for _, k := range keys {
159 if strings.EqualFold(k, "from") {
160 return true
161 }
162 }
163 return false
164}
internal/mailin/dkim_test.go added +327
@@ -0,0 +1,327 @@
1package mailin
2
3import (
4 "bytes"
5 "context"
6 "crypto"
7 "crypto/ed25519"
8 "crypto/rsa"
9 "crypto/x509"
10 "encoding/base64"
11 "encoding/pem"
12 "errors"
13 "math/big"
14 "net"
15 "strings"
16 "testing"
17 "time"
18
19 "github.com/emersion/go-msgauth/dkim"
20)
21
22// Fixed test keys: RSA 2048 and Ed25519.
23const rsaPEM = `-----BEGIN PRIVATE KEY-----
24MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC6i925olmivu5U
25iNMbgSLK4SEZNq4TIPQYPwJ3fHHFa+6V3jtD/2HllkZCrRlBAvra7H8q7TfoCj2K
26lN7hLGkawZM0x9qhxn+AUKuTTL3XeRdl3HQejfuuQvhAQkBU53lF2v0mqzVAEz/k
27cxcNT068yWeCT4GWyT4/A1yy1lfTzyZXrKNcdEDQ5ah8M47kaEYeeRAza19roV3F
28StAggPG/ORC64JXQkwbIEOgNNUYp7hUUWen+kKd5qsuoxaYaWGJ/cEHeEFzsaZN2
29fA//qFAt3jwfbQvnpPzp1c6txkiDnYJgHXp5gRcdDuZ8q85bmeqZf1OBCR2d7Ery
30q1L1sFdRAgMBAAECggEADoc2DW8HbBVSmmLNjibQftxpp30KsZKvb/P4TTXz5lwx
31iJp2IyWQikDZ1/eDL/z7bHFetgkjgX7KrDBL6116EgthW4r1DARZibS+qAoh/tX/
32bH9uy7JjF38/tkFyoSol17rmXEyZKRRWtYQBF5hFmY5V8WAfx46EuoOYhJUM4gHt
333hN6xqvGEjLTgb1xLf08+ntgTzPW3QSiE3A0b0nyRbu8t8PjP0DoABdORuI16hsi
348V1wUoz1iTKGtV1a98moSxyWWC0udBp+tdhZJ+yun5zLqd/cALvXsl8Y7cIIuyLa
35B/mLTkM1SX9swZ06tSa4vRd9fPQz0J5zbgo8Mf3FwQKBgQDH7SuSZ3YpTMhoAmz/
36V6UVN4NNkYkHqNEB3bv0VRG4bPtLXJH9vyhchtwHQMsKTwbqgsFiUON2VwZ0/pfc
370hDooWQHaoVJWRjfZQLD5K42QKQlcDowyvMYT046UViJOSQeTj4IbT0/2EehkHXA
38qCoITU2I6zIfF1Te8yI1wFmdTwKBgQDu3f9rIZq+ihaLrrGMkagofWmVKUzFqVwK
39ckcKXZJ0uQ/ns1er+SITVU3WdOrLsFE+zpE5CH8LG9FIoFhNcCwvzCXV5iACqIi8
404PgkxNDYTo9kMW3yWuWiFcZpLvA7BOCnvW4gmmIofLe1OMBE9F5VquECibeoamPb
41uQgzELnZXwKBgB24BbgXpRryjP/ZDHbQgnuq6tvG/IWk9JzAZ0YktyOhH6HOOu1r
42UwaeDWsOmKAJq0+E7FY/C/D1csJFbjGnEFhkVUg871893VKn40dXYQYzibL/Acdr
43A8PjVg+ZM/4B/np6ywHZqzcoYU2E+dwPo1/kjdgCjkrM3xLdNYKj+y5FAoGAOJaz
44OggeBuHj8XeTbH/dXKpJZyL/oxw6R+dG2TfNyIVHNVcRgBZncjkVVachMNw2gzCg
45yuguYM1YSWJjSQU4EqLEm+YG01pl+ok5gEx4RaZm5g+nwnCyUjHibWzHUNQY/OQt
46wN+SPZE+XFpzgmJ6LsVqxRUnQ2jg+17ciGx/+vUCgYArCRxfa4ecYlZQYTvtbpwY
47pLt6iUht7y69jkwSUTkOn+ZjBDcVWrJwfjt8R9BkMB/2rcwUj4Yo9DcgiWkfHSpM
48W5QuMVb8coft4mC7G37mEoWWdNrc0TLkbfTSr+liNXoWp0QGL7/RQO7HHK+9QVD0
49FfatkTVO1YuBsyGp9eE5rQ==
50-----END PRIVATE KEY-----`
51
52const edPEM = `-----BEGIN PRIVATE KEY-----
53MC4CAQAwBQYDK2VwBCIEICVufJC+iEzea5y5QlUD39QNmX2n/0c93QCcQrfQH8W8
54-----END PRIVATE KEY-----`
55
56var rsaKey, edKey = parseKey(rsaPEM), parseKey(edPEM)
57
58func parseKey(s string) crypto.Signer {
59 b, _ := pem.Decode([]byte(s))
60 k, err := x509.ParsePKCS8PrivateKey(b.Bytes)
61 if err != nil {
62 panic(err)
63 }
64 return k.(crypto.Signer)
65}
66
67func keyRecord(pub crypto.PublicKey) string {
68 switch k := pub.(type) {
69 case ed25519.PublicKey:
70 return "v=DKIM1; k=ed25519; p=" + base64.StdEncoding.EncodeToString(k)
71 default:
72 b, err := x509.MarshalPKIXPublicKey(k)
73 if err != nil {
74 panic(err)
75 }
76 return "v=DKIM1; k=rsa; p=" + base64.StdEncoding.EncodeToString(b)
77 }
78}
79
80// fakeDNS answers by selector whatever the domain: rsa, ed and key1 are
81// the fixed keys, short is a 512-bit RSA key, temp fails temporarily,
82// and anything else does not exist.
83type fakeDNS struct{ lookups int }
84
85func (d *fakeDNS) lookup(_ context.Context, name string) ([]string, error) {
86 d.lookups++
87 sel, _, _ := strings.Cut(name, ".")
88 switch sel {
89 case "rsa", "key1":
90 return []string{keyRecord(rsaKey.Public())}, nil
91 case "ed":
92 return []string{keyRecord(edKey.Public())}, nil
93 case "short":
94 n := new(big.Int).Lsh(big.NewInt(1), 511)
95 n.Add(n, big.NewInt(0x2f))
96 return []string{keyRecord(&rsa.PublicKey{N: n, E: 65537})}, nil
97 case "temp":
98 return nil, &net.DNSError{Err: "server misbehaving", Name: name, IsTemporary: true}
99 case "timeout":
100 return nil, context.DeadlineExceeded
101 }
102 return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
103}
104
105var exampleKeys = []string{"from", "to", "subject", "date", "message-id", "mime-version", "content-type"}
106
107func signMsg(t *testing.T, msg, domain, selector string, key crypto.Signer, canon dkim.Canonicalization, mod func(*dkim.SignOptions)) string {
108 t.Helper()
109 o := dkim.SignOptions{Domain: domain, Selector: selector, Signer: key,
110 HeaderCanonicalization: canon, BodyCanonicalization: canon, HeaderKeys: exampleKeys}
111 if mod != nil {
112 mod(&o)
113 }
114 var b bytes.Buffer
115 if err := dkim.Sign(&b, strings.NewReader(msg), &o); err != nil {
116 t.Fatal(err)
117 }
118 return b.String()
119}
120
121func dkimSetup(t *testing.T) (*fixture, *fakeDNS) {
122 t.Helper()
123 f := setup(t)
124 dns := &fakeDNS{}
125 f.p.LookupTXT = dns.lookup
126 f.p.Cfg.Mail.Inbound.RequireDKIM = true
127 for _, a := range []string{"bob@cleberg.net", "bob@mail.example.test", "bob@user.github.io"} {
128 if err := f.st.AddEmail(f.bob, a, "admin", true); err != nil {
129 t.Fatal(err)
130 }
131 }
132 return f, dns
133}
134
135func TestDKIM(t *testing.T) {
136 type tc struct {
137 name, from, domain, selector string
138 key crypto.Signer
139 canon dkim.Canonicalization
140 mod func(*dkim.SignOptions)
141 after func(string) string // applied to the signed message
142 reason string // "" posts
143 retry bool
144 }
145 var relaxed, simple dkim.Canonicalization = dkim.CanonicalizationRelaxed, dkim.CanonicalizationSimple
146 for _, c := range []tc{
147 {name: "rsa relaxed", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed},
148 {name: "rsa simple", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: simple},
149 {name: "ed25519 relaxed", from: "bob@example.test", domain: "example.test", selector: "ed", key: edKey, canon: relaxed},
150 {name: "ed25519 simple", from: "bob@example.test", domain: "example.test", selector: "ed", key: edKey, canon: simple},
151 {name: "the shape Migadu sends", from: "bob@cleberg.net", domain: "cleberg.net", selector: "key1", key: rsaKey, canon: simple,
152 after: func(m string) string {
153 if !strings.Contains(m, "d=cleberg.net;") || !strings.Contains(m, "s=key1;") || !strings.Contains(m, "c=simple/simple;") ||
154 !strings.Contains(m, "h=from:to:subject:date:message-id:mime-version:content-type;") || !strings.Contains(m, "a=rsa-sha256;") {
155 panic("signature not in the expected shape:\n" + m)
156 }
157 return m
158 }},
159 {name: "signing domain a subdomain of From's", from: "bob@example.test", domain: "mail.example.test", selector: "rsa", key: rsaKey, canon: relaxed},
160 {name: "From a subdomain of the signing domain", from: "bob@mail.example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed},
161 {name: "body altered", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
162 after: func(m string) string { return m + "appended\r\n" }, reason: "body hash did not verify"},
163 {name: "header altered", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
164 after: func(m string) string { return strings.Replace(m, "Subject: Re:", "Subject: Fwd:", 1) }, reason: "signature did not verify"},
165 {name: "From not in h=", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
166 after: func(m string) string { return strings.Replace(m, "h=from:to:", "h=to:", 1) }, reason: "From field not signed"},
167 {name: "misaligned d=", from: "bob@example.test", domain: "attacker.example", selector: "rsa", key: rsaKey, canon: relaxed,
168 reason: "d=attacker.example: d= not aligned"},
169 {name: "public suffix d=", from: "bob@user.github.io", domain: "github.io", selector: "rsa", key: rsaKey, canon: relaxed,
170 reason: "d=github.io: d= not aligned"},
171 {name: "expired x=", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
172 mod: func(o *dkim.SignOptions) { o.Expiration = time.Now().Add(-time.Minute) }, reason: "expired"},
173 {name: "l= refused", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
174 after: func(m string) string { return strings.Replace(m, "DKIM-Signature: ", "DKIM-Signature: l=4; ", 1) },
175 reason: "body length"},
176 {name: "rsa-sha1 refused", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
177 after: func(m string) string { return strings.Replace(m, "a=rsa-sha256", "a=rsa-sha1", 1) }, reason: "too weak"},
178 {name: "512-bit key refused", from: "bob@example.test", domain: "example.test", selector: "short", key: rsaKey, canon: relaxed,
179 reason: "too short"},
180 {name: "no key", from: "bob@example.test", domain: "example.test", selector: "gone", key: rsaKey, canon: relaxed,
181 reason: "no key for signature"},
182 {name: "second From field", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
183 after: func(m string) string { return "From: bob@example.test\r\n" + m }, reason: "more than one From field"},
184 {name: "DNS temporary failure", from: "bob@example.test", domain: "example.test", selector: "temp", key: rsaKey, canon: relaxed,
185 reason: "key lookup failed", retry: true},
186 {name: "DNS timeout", from: "bob@example.test", domain: "example.test", selector: "timeout", key: rsaKey, canon: relaxed,
187 reason: "key lookup failed", retry: true},
188 } {
189 t.Run(c.name, func(t *testing.T) {
190 f, _ := dkimSetup(t)
191 m := signMsg(t, f.messageAs(t, f.bob, c.from, "<d@x>", "", "hi"), c.domain, c.selector, c.key, c.canon, c.mod)
192 if c.after != nil {
193 m = c.after(m)
194 }
195 res := f.p.Handle([]byte(m))
196 if c.reason == "" {
197 if !res.Posted {
198 t.Fatalf("not posted: %+v", res)
199 }
200 return
201 }
202 if res.Posted || res.Retry != c.retry || !strings.Contains(res.Reason, c.reason) {
203 t.Fatalf("result %+v, want reason %q retry %v", res, c.reason, c.retry)
204 }
205 audit := f.refusalReasons(t)
206 if c.retry {
207 if audit != "" {
208 t.Fatalf("a retry was audited:\n%s", audit)
209 }
210 return
211 }
212 if !strings.Contains(audit, c.reason) {
213 t.Fatalf("refusal not audited:\n%s", audit)
214 }
215 if strings.Contains(audit, "appended") || strings.Contains(audit, `"hi`) {
216 t.Fatalf("audit carries content:\n%s", audit)
217 }
218 })
219 }
220}
221
222func TestDKIMNoSignature(t *testing.T) {
223 f, _ := dkimSetup(t)
224 res := f.p.Handle([]byte(f.message(t, "bob@example.test", "hi")))
225 if res.Posted || res.Retry || !strings.Contains(res.Reason, "no DKIM-Signature") {
226 t.Fatalf("result %+v", res)
227 }
228}
229
230func TestDKIMFutureTime(t *testing.T) {
231 f, _ := dkimSetup(t)
232 m := signMsg(t, f.message(t, "bob@example.test", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
233 f.p.Now = func() time.Time { return time.Now().Add(-time.Hour) }
234 if res := f.p.Handle([]byte(m)); res.Posted || !strings.Contains(res.Reason, "dated in the future") {
235 t.Fatalf("result %+v", res)
236 }
237}
238
239// Only the first maxSignatures signatures are checked.
240func TestDKIMTooManySignatures(t *testing.T) {
241 for _, bad := range []int{maxSignatures - 1, maxSignatures} {
242 f, _ := dkimSetup(t)
243 msg := f.messageAs(t, f.bob, "bob@example.test", "<many@x>", "", "hi")
244 m := signMsg(t, msg, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
245 for i := 0; i < bad; i++ {
246 s := signMsg(t, msg, "attacker.example", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
247 m = strings.TrimSuffix(s, msg) + m
248 }
249 res := f.p.Handle([]byte(m))
250 if want := bad < maxSignatures; res.Posted != want {
251 t.Fatalf("%d misaligned signatures first: posted %v, want %v (%+v)", bad, res.Posted, want, res)
252 }
253 }
254}
255
256// With both set, either passing is enough.
257func TestDKIMOrAuthenticationResults(t *testing.T) {
258 f, _ := dkimSetup(t)
259 f.p.Cfg.Mail.Inbound.TrustedAuthservID = "mx.example.net"
260 signed := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<one@x>", "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
261 if res := f.p.Handle([]byte(signed)); !res.Posted {
262 t.Fatalf("DKIM pass, no Authentication-Results: %+v", res)
263 }
264 ar := "Authentication-Results: mx.example.net; dmarc=pass header.from=example.test\r\n"
265 if res := f.p.Handle([]byte(f.messageAs(t, f.bob, "bob@example.test", "<two@x>", ar, "hi"))); !res.Posted {
266 t.Fatalf("Authentication-Results pass, no signature: %+v", res)
267 }
268 res := f.p.Handle([]byte(f.messageAs(t, f.bob, "bob@example.test", "<three@x>", "", "hi")))
269 if res.Posted || !strings.Contains(res.Reason, "no Authentication-Results") || !strings.Contains(res.Reason, "no DKIM-Signature") {
270 t.Fatalf("neither: %+v", res)
271 }
272}
273
274func TestDKIMKeyCache(t *testing.T) {
275 f, dns := dkimSetup(t)
276 for _, id := range []string{"<c1@x>", "<c2@x>"} {
277 m := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", id, "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
278 if res := f.p.Handle([]byte(m)); !res.Posted {
279 t.Fatalf("%s: %+v", id, res)
280 }
281 }
282 if dns.lookups != 1 {
283 t.Fatalf("%d lookups, want 1", dns.lookups)
284 }
285 f.p.Now = func() time.Time { return time.Now().Add(keyCacheTTL + time.Minute) }
286 f.p.lookupKey("rsa._domainkey.example.test")
287 if dns.lookups != 2 {
288 t.Fatalf("%d lookups after the TTL, want 2", dns.lookups)
289 }
290}
291
292func TestKeyCacheBounded(t *testing.T) {
293 var c keyCache
294 now := time.Now()
295 for i := 0; i < keyCacheSize*2; i++ {
296 c.put(strings.Repeat("x", i+1), nil, now)
297 }
298 if len(c.m) > keyCacheSize {
299 t.Fatalf("%d entries", len(c.m))
300 }
301}
302
303// A temporary DNS failure leaves the message unseen for the next poll.
304func TestDKIMRetryInDrain(t *testing.T) {
305 f, _ := dkimSetup(t)
306 m := signMsg(t, f.message(t, "bob@example.test", "hi"), "example.test", "temp", rsaKey, dkim.CanonicalizationRelaxed, nil)
307 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{1: []byte(m)}}
308 if err := f.p.Drain(mb); err != nil {
309 t.Fatal(err)
310 }
311 if mb.seen[1] || f.p.tries[1] != 1 {
312 t.Fatalf("seen %v tries %d", mb.seen[1], f.p.tries[1])
313 }
314}
315
316func TestLookupKeyErrors(t *testing.T) {
317 p := &Processor{LookupTXT: (&fakeDNS{}).lookup}
318 var de *net.DNSError
319 if _, err := p.lookupKey("gone._domainkey.x"); !errors.As(err, &de) || !de.IsNotFound || de.Temporary() {
320 t.Fatalf("not found: %v", err)
321 }
322 for _, sel := range []string{"temp", "timeout"} {
323 if _, err := p.lookupKey(sel + "._domainkey.x"); !errors.As(err, &de) || !de.Temporary() {
324 t.Fatalf("%s: %v", sel, err)
325 }
326 }
327}
internal/mailin/fuzz_test.go +31
@@ -4,9 +4,12 @@ import (
44 "bytes"
55 "net/mail"
66 "net/textproto"
7 "strings"
78 "testing"
89 "time"
910
11 "github.com/emersion/go-msgauth/dkim"
12
1013 "gitbay.org/gitbay/internal/mailreply"
1114)
1215
@@ -33,3 +36,31 @@ func FuzzReply(f *testing.F) {
3336}
3437
3538var fuzzNow = time.Date(2026, 9, 29, 0, 0, 0, 0, time.UTC)
39
40// FuzzDKIM runs the DKIM check, the dkim package's signature and key
41// record parsing included, on arbitrary messages: no input panics.
42func FuzzDKIM(f *testing.F) {
43 msg := "From: bob@example.test\r\nTo: x@y\r\nSubject: s\r\n\r\nhi\r\n"
44 for _, canon := range []dkim.Canonicalization{dkim.CanonicalizationSimple, dkim.CanonicalizationRelaxed} {
45 var b bytes.Buffer
46 o := dkim.SignOptions{Domain: "example.test", Selector: "rsa", Signer: rsaKey,
47 HeaderCanonicalization: canon, BodyCanonicalization: canon}
48 if err := dkim.Sign(&b, strings.NewReader(msg), &o); err != nil {
49 f.Fatal(err)
50 }
51 f.Add(b.Bytes())
52 }
53 f.Add([]byte("DKIM-Signature: v=1; a=ed25519-sha256; d=example.test; s=temp; h=from; bh=; b=\r\nFrom: a@example.test\r\n\r\n"))
54 f.Fuzz(func(t *testing.T, raw []byte) {
55 msg, err := mail.ReadMessage(bytes.NewReader(raw))
56 if err != nil {
57 return
58 }
59 from, err := msg.Header.AddressList("From")
60 if err != nil || len(from) != 1 {
61 return
62 }
63 p := &Processor{LookupTXT: (&fakeDNS{}).lookup}
64 p.dkimVerified(raw, msg.Header, from[0].Address)
65 })
66}
internal/mailin/mailin.go +39 −3
@@ -48,7 +48,11 @@ type Processor struct {
4848 St *store.Store
4949 Cfg config.Config
5050 Now func() time.Time
51 // LookupTXT resolves DKIM selector keys; nil is the system
52 // resolver.
53 LookupTXT LookupTXT
5154
55 keys keyCache
5256 tries map[uint32]int
5357 // A window of refusal rows, bounded because anyone can send mail
5458 // to the mailbox.
@@ -197,10 +201,11 @@ func (p *Processor) Handle(raw []byte) Result {
197201 if !ok {
198202 return p.refuse(u.ID, msgID, "From is not a verified address of the account")
199203 }
200 if id := p.Cfg.Mail.Inbound.TrustedAuthservID; id != "" {
201 if reason := authenticated(msg.Header, id, from[0].Address); reason != "" {
202 return p.refuse(u.ID, msgID, reason)
204 if res := p.authenticate(raw, msg.Header, from[0].Address); res != nil {
205 if res.Retry {
206 return *res
203207 }
208 return p.refuse(u.ID, msgID, res.Reason)
204209 }
205210 if on, err := p.St.ReplyEnabled(u.ID); err != nil {
206211 return Result{Retry: true, Reason: err.Error()}
@@ -271,6 +276,37 @@ func (p *Processor) Handle(raw []byte) Result {
271276 return p.refuse(u.ID, msgID, "comment refused: "+reason)
272277}
273278
279// authenticate checks that the mail host or the sender's domain vouches
280// for From: an Authentication-Results pass from trusted_authserv_id, or
281// a DKIM signature that verifies here with require_dkim. When both are
282// set either is enough. It returns nil when From is authenticated or
283// neither is set, and the unaudited refusal or retry otherwise.
284func (p *Processor) authenticate(raw []byte, h mail.Header, from string) *Result {
285 in := p.Cfg.Mail.Inbound
286 var reasons []string
287 if id := in.TrustedAuthservID; id != "" {
288 reason := authenticated(h, id, from)
289 if reason == "" {
290 return nil
291 }
292 reasons = append(reasons, reason)
293 }
294 if in.RequireDKIM {
295 reason, retry := p.dkimVerified(raw, h, from)
296 if reason == "" {
297 return nil
298 }
299 if retry {
300 return &Result{Retry: true, Reason: reason}
301 }
302 reasons = append(reasons, reason)
303 }
304 if len(reasons) == 0 {
305 return nil
306 }
307 return &Result{Reason: strings.Join(reasons, "; ")}
308}
309
274310// createdAfter refuses when the row was created after the token was
275311// minted: a later account or repository that took a freed id. Created
276312// times are compared to the second, the token's precision.