mail: verify DKIM on reply mail !538

merged merged by cmc on 2026-09-29 15:50 UTC · krz/gitbay:dkim-307 into main

16 files changed, +1102 −44

Layout: unified · split

.gitbay/wiki/Admin.org +61 −15
@@ -163,6 +163,7 @@ password_file = "/etc/gitbay/imap.pass" # one line, mode 0600, owned by gitbayd
163mailbox = "INBOX" # default 163mailbox = "INBOX" # default
164poll_interval = "1m" # default; at least 10s 164poll_interval = "1m" # default; at least 10s
165reply_address = "reply@gitbay.example" 165reply_address = "reply@gitbay.example"
166require_dkim = true # recommended; see below
166trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Results id 167trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Results id
167#+end_src 168#+end_src
168 169
@@ -192,9 +193,48 @@ trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Result
192 server that sends more than about 11 MiB, or more than a thousand 193 server that sends more than about 11 MiB, or more than a thousand
193 untagged responses, for one command has its connection closed; one 194 untagged responses, for one command has its connection closed; one
194 poll handles at most ten thousand messages. 195 poll handles at most ten thousand messages.
195- =trusted_authserv_id= is required on any instance reachable from the 196- Whatever the settings, a message is refused when a header field name
196 internet. It names the authserv-id the mail host writes at the start 197 is not RFC 5322 =ftext= (=From : x=, a space or a non-ASCII byte in a
197 of its =Authentication-Results= header (Gmail's is =mx.google.com=). 198 name), when it does not have exactly one =From=, or when it has more
199 than one =To=, =Cc=, =Message-ID=, =Content-Type= or
200 =Content-Transfer-Encoding=.
201- =require_dkim= (default false) should be on for any instance
202 reachable from the internet. With it, a reply is posted only when
203 gitbayd itself verifies one of its DKIM signatures (RFC 6376) with a
204 =d== in relaxed alignment with the From domain (the same
205 organizational domain by the public suffix list; =d=github.io= aligns
206 with nothing) and an =h== that covers =From=, the =To= or =Cc= holding
207 the reply address, =Content-Type=, and =Message-ID= when the message
208 has one. A =Content-Transfer-Encoding= outside =h== is accepted only
209 when it is =7bit=, =8bit= or =binary=, which leave the decoded body as
210 it is; Thunderbird, for one, does not sign it. An unsigned
211 =quoted-printable= or =base64= is refused. The reply
212 address is read only from =To= or =Cc=: a reply that reached the
213 mailbox by Bcc, with the address only in =Delivered-To=, is refused
214 ("reply address not in To or Cc"). It needs nothing from the mail
215 host, so it works where the host adds no =Authentication-Results=.
216 rsa-sha256 (keys of 1024 bits or more) and ed25519-sha256 are
217 accepted, with simple or relaxed canonicalization; rsa-sha1, a body
218 length tag (=l==), an expired =x== and a =t== more than fifteen
219 minutes ahead are refused. Only the first five signatures are
220 checked. The key is looked up at =<s>._domainkey.<d>= with a
221 five-second timeout and cached for fifteen minutes (the resolver does
222 not report the record's TTL); a lookup that fails for a reason that
223 may pass (a timeout, SERVFAIL) leaves the message for the next poll,
224 up to the five tries above, while a missing key refuses it.
225 Signatures are checked on the message as fetched. Each passing
226 signature is recorded with the =Message-ID=, so a copy of the same
227 signed message posts once even with unsigned fields changed.
228- Either =require_dkim= or =trusted_authserv_id= passing is enough to
229 authenticate =From=; when both are set, a reply needs only one of
230 them, and a refusal names both reasons. With only
231 =trusted_authserv_id=, the reply address may come from any recipient
232 field (=Delivered-To=, =X-Original-To=, =Envelope-To=, =To=, =Cc=),
233 since the mail host vouches for the sender and not for the fields.
234 Set both when the mail host adds =Authentication-Results= for most
235 senders but not all.
236- =trusted_authserv_id= names the authserv-id the mail host writes at
237 the start of its =Authentication-Results= header (Gmail's is =mx.google.com=).
198 With it set, a reply is posted only when the topmost header with that 238 With it set, a reply is posted only when the topmost header with that
199 id shows =dmarc=pass= with =header.from= equal to the From domain, or 239 id shows =dmarc=pass= with =header.from= equal to the From domain, or
200 =dkim=pass= with a =header.d= in relaxed alignment with it (the same 240 =dkim=pass= with a =header.d= in relaxed alignment with it (the same
@@ -205,19 +245,24 @@ trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Result
205 claiming the same id are the sender's and are not read. This is only safe when the mail host 245 claiming the same id are the sender's and are not read. This is only safe when the mail host
206 removes incoming =Authentication-Results= headers that claim its id, 246 removes incoming =Authentication-Results= headers that claim its id,
207 as RFC 8601 asks; Gmail, Fastmail and Migadu do. Check yours before 247 as RFC 8601 asks; Gmail, Fastmail and Migadu do. Check yours before
208 relying on it. Unset, the daemon logs a warning at start and =admin 248 relying on it. With neither this nor =require_dkim= set, the daemon
209 mail inbound check= repeats it: without it, =From= is whatever the 249 logs a warning at start and =admin mail inbound check= repeats it:
210 sender wrote. Mail between two addresses at the same host may carry 250 =From= is then whatever the sender wrote. Mail between two addresses
211 no =Authentication-Results= at all: at Migadu, mail from another 251 at the same host may carry no =Authentication-Results= at all: at
252 Migadu, mail from another
212 Migadu-hosted domain is delivered through its outbound path and gets 253 Migadu-hosted domain is delivered through its outbound path and gets
213 none, so setting the id refuses every reply from such users. 254 none, so setting the id alone refuses every reply from such users.
214 gitbay.org runs without it for that reason until gitbayd verifies 255 That mail does carry a DKIM signature aligned with =From= (for
215 DKIM itself (#307). 256 example =d=cleberg.net; s=key1; a=rsa-sha256; c=simple/simple;
257 h=from:to:subject:date:message-id:mime-version:content-type=), which
258 is why gitbay.org sets =require_dkim= instead.
216- =gitbay admin mail inbound check= logs in, opens the mailbox 259- =gitbay admin mail inbound check= logs in, opens the mailbox
217 read-only (EXAMINE) and reports the message and unseen counts, so a 260 read-only (EXAMINE) and reports the message and unseen counts and how
218 check never marks a reply seen before the poller reads it. With 261 =From= is authenticated (=require_dkim=, =trusted_authserv_id=), so a
219 inbound off it says so and exits 0. Poll failures are logged as 262 check never marks a reply seen before the poller reads it. It warns
220 =mail reply: poll failed= with the server and the IMAP error. 263 when neither is set. With inbound off it says so and exits 0. Poll
264 failures are logged as =mail reply: poll failed= with the server and
265 the IMAP error.
221 266
222A reply is posted when all of these hold, checked when it is read: 267A reply is posted when all of these hold, checked when it is read:
223 268
@@ -230,7 +275,8 @@ A reply is posted when all of these hold, checked when it is read:
230 by a delete and reused is not the account the token named. The same 275 by a delete and reused is not the account the token named. The same
231 holds for the repository. 276 holds for the repository.
2324. =From= is one of that account's verified addresses, and, with 2774. =From= is one of that account's verified addresses, and, with
233 =trusted_authserv_id= set, the mail host authenticated it. 278 =require_dkim= or =trusted_authserv_id= set, a DKIM signature
279 gitbayd verified or the mail host's result authenticated it.
2345. The message has a =text/plain= part (HTML-only mail is refused, not 2805. The message has a =text/plain= part (HTML-only mail is refused, not
235 converted), and what is left after quoted text and the signature 281 converted), and what is left after quoted text and the signature
236 are removed is not empty and fits a comment (64 KiB). 282 are removed is not empty and fits a comment (64 KiB).
.gitbay/wiki/Architecture/03-Deployment.org +1
@@ -62,6 +62,7 @@ a database check.
62| SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) | 62| SMTP relay | queued mail | STARTTLS required for a non-local relay, or implicit TLS | =mail.require_tls=; Go's =PlainAuth= will not send credentials over plaintext to a non-local host (=internal/mail/mail.go=) |
63| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key | 63| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key |
64| IMAP server | =mail.inbound.poll_interval= | implicit TLS or STARTTLS, certificate verified; no plaintext setting | operator-configured host only (=internal/imapc=) | 64| IMAP server | =mail.inbound.poll_interval= | implicit TLS or STARTTLS, certificate verified; no plaintext setting | operator-configured host only (=internal/imapc=) |
65| DNS resolver | =mail.inbound.require_dkim=, a reply that passed the token and address checks | no; the system resolver, no DNSSEC validation in gitbayd | name is =<s>._domainkey.<d>= from the signature; five-second timeout, fifteen-minute cache of at most 256 keys, first five signatures only (=internal/mailin/dkim.go=) |
65| Webhook URLs | recorded events | yes when https; certificate verified | private, shared, loopback, link-local and multicast targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) | 66| Webhook URLs | recorded events | yes when https; certificate verified | private, shared, loopback, link-local and multicast targets refused at save and again at connect time; no redirects (=internal/webhook/webhook.go=) |
66| Mirror URLs | mirror schedule | per URL | address check at save and before each sync; git pinned to the checked addresses, no redirects (=internal/mirror/mirror.go=) | 67| Mirror URLs | mirror schedule | per URL | address check at save and before each sync; git pinned to the checked addresses, no redirects (=internal/mirror/mirror.go=) |
67| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) | 68| Package registries | dependency checks | yes | fixed hosts; only the package name varies (=internal/deps/registry.go=) |
.gitbay/wiki/Threat-Model.org +35 −3
@@ -124,9 +124,41 @@ things are required together, because neither is enough alone:
124 mail host echoes into its header (a quoted MAIL FROM local part, a 124 mail host echoes into its header (a quoted MAIL FROM local part, a
125 reason) cannot read as a result; =FuzzAuthResults= checks that. That rests on 125 reason) cannot read as a result; =FuzzAuthResults= checks that. That rests on
126 the mail host removing incoming headers that claim its id (RFC 8601 126 the mail host removing incoming headers that claim its id (RFC 8601
127 §5); Gmail, Fastmail and Migadu do. Unset, the daemon warns at start, 127 §5); Gmail, Fastmail and Migadu do. With =require_dkim= set, gitbayd
128 and a leaked token plus a forged From posts. The Admin page calls it 128 verifies the message's DKIM signatures itself, on the bytes as
129 required for any exposed instance. 129 fetched, and requires one whose =d= has the same organizational
130 domain as From; this depends on the sender's domain signing, not on
131 the mail host, and is what an instance whose host adds no
132 =Authentication-Results= for some senders (gitbay.org, at Migadu)
133 relies on. The signature's =h= must cover From, the To or Cc the
134 reply address is read from (a signed message cannot be redirected to
135 another token by adding an unsigned Cc or by Bcc), Content-Type, and
136 Message-ID when present. An unsigned Content-Transfer-Encoding is
137 accepted only as 7bit, 8bit or binary, identity encodings, so adding
138 one cannot change what the signed body decodes to; the encodings in
139 MIME parts are inside the body and covered by =bh=. Header field
140 names are checked on the raw header before anything reads it: a name
141 outside RFC 5322 =ftext= such as =From : x= is one net/mail and the
142 DKIM verifier would file under different names, so a forged From
143 could be read while the signature covers another; such a message is
144 refused, as is one without exactly one From or with a repeated To,
145 Cc, Message-ID, Content-Type or Content-Transfer-Encoding. Signatures
146 with a body length tag are refused, since content appended after the
147 signed length would verify, as are rsa-sha1, keys under 1024 bits and
148 expired signatures. Each passing signature's =b= is recorded with the
149 Message-ID, so a replayed copy does not post twice. Keys are cached
150 for fifteen minutes, so a revoked key is still honoured for up to
151 that long. A DNS failure that may pass
152 delays the reply rather than refusing it; the key lookup is bounded by
153 a five-second timeout and only the first five signatures are checked,
154 so a message cannot make gitbayd wait on many lookups. The key comes
155 from the system resolver and gitbayd does not validate DNSSEC itself;
156 whoever can forge the resolver's answers can forge the key. With both
157 set, either passing is enough; with only =trusted_authserv_id=, the
158 reply address may come from any recipient field, as the mail host
159 vouches for the sender and not for the fields. With neither, the daemon warns at start, and
160 a leaked token plus a forged From posts. The Admin page recommends
161 =require_dkim= for any exposed instance.
130- *Reused ids.* Account and repository ids are reused after a hard 162- *Reused ids.* Account and repository ids are reused after a hard
131 delete. A reply is refused when the account or repository was 163 delete. A reply is refused when the account or repository was
132 created after its token was minted, so a token cannot post into a 164 created after its token was minted, so a token cannot post into a
CHANGELOG.org +20
@@ -4,6 +4,26 @@ Versioning follows semver from v0.1.0. Database migrations run
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
7* Unreleased
8
9- =[mail.inbound] require_dkim= makes gitbayd verify a reply's DKIM
10 signature itself: one of the first five signatures must verify
11 (rsa-sha256 with a key of 1024 bits or more, or ed25519-sha256), have
12 a =d== in relaxed alignment with the From domain, and cover From, the
13 To or Cc holding the reply address, Content-Type, and Message-ID when
14 present; an unsigned Content-Transfer-Encoding must be 7bit, 8bit or
15 binary. The reply address is then
16 read only from To or Cc. =l==, rsa-sha1, an expired =x== and a =t==
17 in the future are refused; a temporary DNS failure retries on the
18 next poll; keys are cached for fifteen minutes; a passing signature
19 is recorded so a copy posts once. Off by default; either it or
20 =trusted_authserv_id= passing is enough. The start-up warning and
21 =admin mail inbound check= now warn only when neither is set, and the
22 check reports both settings. (#307)
23- A reply whose header has a field name outside RFC 5322 =ftext=
24 (=From : x=), no single From, or a repeated To, Cc, Message-ID,
25 Content-Type or Content-Transfer-Encoding is refused. (#307)
26
7* v1.39.0 — 2026-09-29 27* v1.39.0 — 2026-09-29
8 28
9Suggested changes, split diffs, reactions, saved 29Suggested changes, split diffs, reactions, saved
cmd/gitbayd/main.go +2 −2
@@ -213,8 +213,8 @@ func serveCmd() *cobra.Command {
213 if _, err := in.Password(); err != nil { 213 if _, err := in.Password(); err != nil {
214 return err 214 return err
215 } 215 }
216 if in.TrustedAuthservID == "" { 216 if !in.Authenticated() {
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") 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")
218 } 218 }
219 go (&mailin.Poller{P: &mailin.Processor{St: st, Cfg: cfg}, In: in}).Run(whCtx) 219 go (&mailin.Poller{P: &mailin.Processor{St: st, Cfg: cfg}, In: in}).Run(whCtx)
220 } 220 }
deploy/audit.sh +1
@@ -18,6 +18,7 @@ go test -run xxx -fuzz FuzzParsePGPKey -fuzztime 10s ./internal/sig/
18go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/ 18go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/
19go test -run xxx -fuzz FuzzReply -fuzztime 10s ./internal/mailin/ 19go test -run xxx -fuzz FuzzReply -fuzztime 10s ./internal/mailin/
20go test -run xxx -fuzz FuzzAuthResults -fuzztime 10s ./internal/mailin/ 20go test -run xxx -fuzz FuzzAuthResults -fuzztime 10s ./internal/mailin/
21go test -run xxx -fuzz FuzzDKIM -fuzztime 10s ./internal/mailin/
21go test -run xxx -fuzz FuzzReadResponse -fuzztime 10s ./internal/imapc/ 22go test -run xxx -fuzz FuzzReadResponse -fuzztime 10s ./internal/imapc/
22 23
23echo "== all clear ==" 24echo "== all clear =="
go.mod +1
@@ -7,6 +7,7 @@ require (
7 github.com/BurntSushi/toml v1.6.0 7 github.com/BurntSushi/toml v1.6.0
8 github.com/ProtonMail/go-crypto v1.5.2 8 github.com/ProtonMail/go-crypto v1.5.2
9 github.com/alecthomas/chroma/v2 v2.27.0 9 github.com/alecthomas/chroma/v2 v2.27.0
10 github.com/emersion/go-msgauth v0.7.0
10 github.com/microcosm-cc/bluemonday v1.0.27 11 github.com/microcosm-cc/bluemonday v1.0.27
11 github.com/niklasfasching/go-org v1.9.1 12 github.com/niklasfasching/go-org v1.9.1
12 github.com/spf13/cobra v1.10.2 13 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
30github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= 30github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
31github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 31github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
32github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 32github.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=
33github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= 35github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
34github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= 36github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
35github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 37github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
internal/config/config.go +10
@@ -302,6 +302,16 @@ type MailInbound struct {
302 // pass, or an aligned DKIM pass, in the topmost such header. Only 302 // pass, or an aligned DKIM pass, in the topmost such header. Only
303 // safe when the mail host removes incoming headers claiming its id. 303 // safe when the mail host removes incoming headers claiming its id.
304 TrustedAuthservID string `toml:"trusted_authserv_id"` 304 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
305} 315}
306 316
307// DefaultInboundPoll is the poll interval when poll_interval is unset. 317// 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) {
423 if in.Addr() != "imap.example:993" || in.MailboxName() != "INBOX" || in.Poll() != DefaultInboundPoll { 423 if in.Addr() != "imap.example:993" || in.MailboxName() != "INBOX" || in.Poll() != DefaultInboundPoll {
424 t.Fatalf("defaults: %q %q %v", in.Addr(), in.MailboxName(), in.Poll()) 424 t.Fatalf("defaults: %q %q %v", in.Addr(), in.MailboxName(), in.Poll())
425 } 425 }
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 }
426 in.TLS = "starttls" 433 in.TLS = "starttls"
427 if in.Addr() != "imap.example:143" { 434 if in.Addr() != "imap.example:143" {
428 t.Fatalf("starttls default port: %q", in.Addr()) 435 t.Fatalf("starttls default port: %q", in.Addr())
internal/control/adminmail.go +11 −4
@@ -17,7 +17,7 @@ func init() {
17 ReadOnly: true, Run: runAdminMailInboundCheck}) 17 ReadOnly: true, Run: runAdminMailInboundCheck})
18} 18}
19 19
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"
21 21
22// runAdminMailInboundCheck logs in to the [mail.inbound] mailbox and 22// runAdminMailInboundCheck logs in to the [mail.inbound] mailbox and
23// opens it with EXAMINE, which changes no flag, so a check never marks a 23// 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 {
36 Mailbox string `json:"mailbox,omitempty"` 36 Mailbox string `json:"mailbox,omitempty"`
37 Messages int `json:"messages"` 37 Messages int `json:"messages"`
38 Unseen int `json:"unseen"` 38 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"`
40 } 44 }
41 if !in.Enabled { 45 if !in.Enabled {
42 return c.emit(out{}, func(w io.Writer) { 46 return c.emit(out{}, func(w io.Writer) {
@@ -52,8 +56,9 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
52 if err != nil { 56 if err != nil {
53 return c.fail(protocol.ExitFailure, "%s: %v", in.Addr(), err) 57 return c.fail(protocol.ExitFailure, "%s: %v", in.Addr(), err)
54 } 58 }
55 d := out{Enabled: true, Server: in.Addr(), Mailbox: in.MailboxName(), Messages: n, Unseen: len(unseen)} 59 d := out{Enabled: true, Server: in.Addr(), Mailbox: in.MailboxName(), Messages: n, Unseen: len(unseen),
56 if in.TrustedAuthservID == "" { 60 RequireDKIM: in.RequireDKIM, TrustedAuthservID: in.TrustedAuthservID}
61 if !in.Authenticated() {
57 d.Warning = unauthenticatedWarning 62 d.Warning = unauthenticatedWarning
58 fmt.Fprintln(c.Stderr, "warning: "+d.Warning) 63 fmt.Fprintln(c.Stderr, "warning: "+d.Warning)
59 } 64 }
@@ -63,6 +68,8 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int {
63 "mailbox", d.Mailbox, 68 "mailbox", d.Mailbox,
64 "messages", fmt.Sprintf("%d", d.Messages), 69 "messages", fmt.Sprintf("%d", d.Messages),
65 "unseen", fmt.Sprintf("%d", d.Unseen), 70 "unseen", fmt.Sprintf("%d", d.Unseen),
71 "require_dkim", fmt.Sprintf("%t", d.RequireDKIM),
72 "trusted_authserv_id", d.TrustedAuthservID,
66 ) 73 )
67 }) 74 })
68} 75}
internal/mailin/dkim.go added +202
@@ -0,0 +1,202 @@
1package mailin
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/hex"
8 "errors"
9 "net"
10 "strings"
11 "sync"
12 "time"
13
14 "github.com/emersion/go-msgauth/dkim"
15)
16
17const (
18 // maxSignatures is how many DKIM-Signature fields are checked; any
19 // after them are ignored.
20 maxSignatures = 5
21 // dnsTimeout bounds one selector key lookup.
22 dnsTimeout = 5 * time.Second
23 // futureSkew is how far ahead of this clock a signature's t= may be.
24 futureSkew = 15 * time.Minute
25 // keyCacheTTL is how long a key record is reused. The resolver API
26 // does not report the record's TTL, so this is short: a revoked key
27 // is still honoured for up to this long.
28 keyCacheTTL = 15 * time.Minute
29 keyCacheSize = 256
30)
31
32// LookupTXT returns the TXT records at name, one string per record.
33type LookupTXT func(ctx context.Context, name string) ([]string, error)
34
35type cachedKey struct {
36 txts []string
37 expires time.Time
38}
39
40// keyCache holds selector key records that resolved, for keyCacheTTL,
41// at most keyCacheSize of them. Failures are not cached.
42type keyCache struct {
43 mu sync.Mutex
44 m map[string]cachedKey
45}
46
47func (c *keyCache) get(name string, now time.Time) ([]string, bool) {
48 c.mu.Lock()
49 defer c.mu.Unlock()
50 e, ok := c.m[name]
51 if !ok || now.After(e.expires) {
52 return nil, false
53 }
54 return e.txts, true
55}
56
57func (c *keyCache) put(name string, txts []string, now time.Time) {
58 c.mu.Lock()
59 defer c.mu.Unlock()
60 if c.m == nil {
61 c.m = map[string]cachedKey{}
62 }
63 if len(c.m) >= keyCacheSize {
64 for k, e := range c.m {
65 if now.After(e.expires) {
66 delete(c.m, k)
67 }
68 }
69 for k := range c.m {
70 if len(c.m) < keyCacheSize {
71 break
72 }
73 delete(c.m, k)
74 }
75 }
76 c.m[name] = cachedKey{txts: txts, expires: now.Add(keyCacheTTL)}
77}
78
79// lookupKey is the verifier's TXT lookup: cached, bounded by dnsTimeout.
80// The dkim package tells a temporary failure from a permanent one by
81// the error implementing net.Error with Temporary true, so every error
82// returned is a *net.DNSError, and one that is not a plain "no such
83// record" is marked temporary.
84func (p *Processor) lookupKey(name string) ([]string, error) {
85 now := p.now()
86 if txts, ok := p.keys.get(name, now); ok {
87 return txts, nil
88 }
89 lookup := p.LookupTXT
90 if lookup == nil {
91 lookup = net.DefaultResolver.LookupTXT
92 }
93 ctx, cancel := context.WithTimeout(context.Background(), dnsTimeout)
94 defer cancel()
95 txts, err := lookup(ctx, name)
96 if err != nil {
97 var de *net.DNSError
98 if errors.As(err, &de) && de.IsNotFound {
99 return nil, &net.DNSError{Err: "no such record", Name: name, IsNotFound: true}
100 }
101 return nil, &net.DNSError{Err: "lookup failed", Name: name, IsTemporary: true}
102 }
103 p.keys.put(name, txts, now)
104 return txts, nil
105}
106
107// dkimVerified checks the DKIM signatures on raw, the message as it
108// was fetched. A signature passes when it is one of the first
109// maxSignatures, verifies, has a d= in relaxed alignment with the From
110// domain, has not expired, is not dated in the future, and its h=
111// covers From, tokenField (the To or Cc the reply address was read
112// from), Content-Type, and Message-ID when the message has one. An
113// unsigned Content-Transfer-Encoding is accepted only when it is an
114// identity encoding (7bit, 8bit, binary), which does not change what
115// the body decodes to; mail clients commonly leave it out of h=. It returns an id for each passing
116// signature (a hash of its b=), or the refusal's reason. retry is true
117// when none passed and one could not be checked because its key lookup
118// failed for a reason that may pass.
119func (p *Processor) dkimVerified(raw []byte, rh rawHeader, from, tokenField string) (ids []string, reason string, retry bool) {
120 fromDomain := ""
121 if i := strings.LastIndex(from, "@"); i >= 0 {
122 fromDomain = strings.ToLower(from[i+1:])
123 }
124 if fromDomain == "" {
125 return nil, "no From domain", false
126 }
127 need := []string{"from", tokenField, "content-type"}
128 if rh.count["message-id"] > 0 {
129 need = append(need, "message-id")
130 }
131 cteOK := true
132 switch rh.cte {
133 case "7bit", "8bit", "binary":
134 default:
135 cteOK = rh.count["content-transfer-encoding"] == 0
136 }
137 verifs, err := dkim.VerifyWithOptions(bytes.NewReader(raw), &dkim.VerifyOptions{
138 LookupTXT: p.lookupKey, MaxVerifications: maxSignatures})
139 if err != nil && !errors.Is(err, dkim.ErrTooManySignatures) {
140 return nil, "DKIM: unreadable message", false
141 }
142 if len(verifs) == 0 {
143 return nil, "no DKIM-Signature", false
144 }
145 now := p.now()
146 var fails []string
147 for i, v := range verifs {
148 d := strings.ToLower(v.Domain)
149 why := ""
150 switch {
151 case dkim.IsTempFail(v.Err):
152 retry = true
153 why = "key lookup failed"
154 case v.Err != nil:
155 why = strings.TrimPrefix(v.Err.Error(), "dkim: ")
156 case !v.Expiration.IsZero() && now.After(v.Expiration):
157 why = "signature has expired"
158 case !v.Time.IsZero() && v.Time.After(now.Add(futureSkew)):
159 why = "signature dated in the future"
160 case !aligned(d, fromDomain):
161 why = "d= not aligned with the From domain"
162 default:
163 if n := unsigned(v.HeaderKeys, need); n != "" {
164 why = n + " not in h="
165 } else if !cteOK && unsigned(v.HeaderKeys, []string{"content-transfer-encoding"}) != "" {
166 why = "content-transfer-encoding not in h= and not 7bit, 8bit or binary"
167 } else if i < len(rh.dkimB) && rh.dkimB[i] != "" {
168 sum := sha256.Sum256([]byte(rh.dkimB[i]))
169 ids = append(ids, "dkim:"+hex.EncodeToString(sum[:]))
170 continue
171 } else {
172 why = "no b= tag"
173 }
174 }
175 if len(d) > 100 {
176 d = d[:100]
177 }
178 fails = append(fails, "d="+d+": "+why)
179 }
180 if len(ids) > 0 {
181 return ids, "", false
182 }
183 return nil, "DKIM: no passing signature aligned with the From domain (" + strings.Join(fails, "; ") + ")", retry
184}
185
186// unsigned returns the first of need that keys (a signature's h=) does
187// not list, or "".
188func unsigned(keys, need []string) string {
189 for _, n := range need {
190 found := false
191 for _, k := range keys {
192 if strings.EqualFold(k, n) {
193 found = true
194 break
195 }
196 }
197 if !found {
198 return n
199 }
200 }
201 return ""
202}
internal/mailin/dkim_test.go added +524
@@ -0,0 +1,524 @@
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 "gitbay.org/gitbay/internal/mailreply"
22)
23
24// Fixed test keys: RSA 2048 and Ed25519.
25const rsaPEM = `-----BEGIN PRIVATE KEY-----
26MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC6i925olmivu5U
27iNMbgSLK4SEZNq4TIPQYPwJ3fHHFa+6V3jtD/2HllkZCrRlBAvra7H8q7TfoCj2K
28lN7hLGkawZM0x9qhxn+AUKuTTL3XeRdl3HQejfuuQvhAQkBU53lF2v0mqzVAEz/k
29cxcNT068yWeCT4GWyT4/A1yy1lfTzyZXrKNcdEDQ5ah8M47kaEYeeRAza19roV3F
30StAggPG/ORC64JXQkwbIEOgNNUYp7hUUWen+kKd5qsuoxaYaWGJ/cEHeEFzsaZN2
31fA//qFAt3jwfbQvnpPzp1c6txkiDnYJgHXp5gRcdDuZ8q85bmeqZf1OBCR2d7Ery
32q1L1sFdRAgMBAAECggEADoc2DW8HbBVSmmLNjibQftxpp30KsZKvb/P4TTXz5lwx
33iJp2IyWQikDZ1/eDL/z7bHFetgkjgX7KrDBL6116EgthW4r1DARZibS+qAoh/tX/
34bH9uy7JjF38/tkFyoSol17rmXEyZKRRWtYQBF5hFmY5V8WAfx46EuoOYhJUM4gHt
353hN6xqvGEjLTgb1xLf08+ntgTzPW3QSiE3A0b0nyRbu8t8PjP0DoABdORuI16hsi
368V1wUoz1iTKGtV1a98moSxyWWC0udBp+tdhZJ+yun5zLqd/cALvXsl8Y7cIIuyLa
37B/mLTkM1SX9swZ06tSa4vRd9fPQz0J5zbgo8Mf3FwQKBgQDH7SuSZ3YpTMhoAmz/
38V6UVN4NNkYkHqNEB3bv0VRG4bPtLXJH9vyhchtwHQMsKTwbqgsFiUON2VwZ0/pfc
390hDooWQHaoVJWRjfZQLD5K42QKQlcDowyvMYT046UViJOSQeTj4IbT0/2EehkHXA
40qCoITU2I6zIfF1Te8yI1wFmdTwKBgQDu3f9rIZq+ihaLrrGMkagofWmVKUzFqVwK
41ckcKXZJ0uQ/ns1er+SITVU3WdOrLsFE+zpE5CH8LG9FIoFhNcCwvzCXV5iACqIi8
424PgkxNDYTo9kMW3yWuWiFcZpLvA7BOCnvW4gmmIofLe1OMBE9F5VquECibeoamPb
43uQgzELnZXwKBgB24BbgXpRryjP/ZDHbQgnuq6tvG/IWk9JzAZ0YktyOhH6HOOu1r
44UwaeDWsOmKAJq0+E7FY/C/D1csJFbjGnEFhkVUg871893VKn40dXYQYzibL/Acdr
45A8PjVg+ZM/4B/np6ywHZqzcoYU2E+dwPo1/kjdgCjkrM3xLdNYKj+y5FAoGAOJaz
46OggeBuHj8XeTbH/dXKpJZyL/oxw6R+dG2TfNyIVHNVcRgBZncjkVVachMNw2gzCg
47yuguYM1YSWJjSQU4EqLEm+YG01pl+ok5gEx4RaZm5g+nwnCyUjHibWzHUNQY/OQt
48wN+SPZE+XFpzgmJ6LsVqxRUnQ2jg+17ciGx/+vUCgYArCRxfa4ecYlZQYTvtbpwY
49pLt6iUht7y69jkwSUTkOn+ZjBDcVWrJwfjt8R9BkMB/2rcwUj4Yo9DcgiWkfHSpM
50W5QuMVb8coft4mC7G37mEoWWdNrc0TLkbfTSr+liNXoWp0QGL7/RQO7HHK+9QVD0
51FfatkTVO1YuBsyGp9eE5rQ==
52-----END PRIVATE KEY-----`
53
54const edPEM = `-----BEGIN PRIVATE KEY-----
55MC4CAQAwBQYDK2VwBCIEICVufJC+iEzea5y5QlUD39QNmX2n/0c93QCcQrfQH8W8
56-----END PRIVATE KEY-----`
57
58var rsaKey, edKey = parseKey(rsaPEM), parseKey(edPEM)
59
60func parseKey(s string) crypto.Signer {
61 b, _ := pem.Decode([]byte(s))
62 k, err := x509.ParsePKCS8PrivateKey(b.Bytes)
63 if err != nil {
64 panic(err)
65 }
66 return k.(crypto.Signer)
67}
68
69func keyRecord(pub crypto.PublicKey) string {
70 switch k := pub.(type) {
71 case ed25519.PublicKey:
72 return "v=DKIM1; k=ed25519; p=" + base64.StdEncoding.EncodeToString(k)
73 default:
74 b, err := x509.MarshalPKIXPublicKey(k)
75 if err != nil {
76 panic(err)
77 }
78 return "v=DKIM1; k=rsa; p=" + base64.StdEncoding.EncodeToString(b)
79 }
80}
81
82// fakeDNS answers by selector whatever the domain: rsa, ed and key1 are
83// the fixed keys, short is a 512-bit RSA key, temp fails temporarily,
84// and anything else does not exist.
85type fakeDNS struct{ lookups int }
86
87func (d *fakeDNS) lookup(_ context.Context, name string) ([]string, error) {
88 d.lookups++
89 sel, _, _ := strings.Cut(name, ".")
90 switch sel {
91 case "rsa", "key1":
92 return []string{keyRecord(rsaKey.Public())}, nil
93 case "ed":
94 return []string{keyRecord(edKey.Public())}, nil
95 case "short":
96 n := new(big.Int).Lsh(big.NewInt(1), 511)
97 n.Add(n, big.NewInt(0x2f))
98 return []string{keyRecord(&rsa.PublicKey{N: n, E: 65537})}, nil
99 case "temp":
100 return nil, &net.DNSError{Err: "server misbehaving", Name: name, IsTemporary: true}
101 case "timeout":
102 return nil, context.DeadlineExceeded
103 }
104 return nil, &net.DNSError{Err: "no such host", Name: name, IsNotFound: true}
105}
106
107var exampleKeys = []string{"from", "to", "subject", "date", "message-id", "mime-version", "content-type"}
108
109func signMsg(t *testing.T, msg, domain, selector string, key crypto.Signer, canon dkim.Canonicalization, mod func(*dkim.SignOptions)) string {
110 t.Helper()
111 o := dkim.SignOptions{Domain: domain, Selector: selector, Signer: key,
112 HeaderCanonicalization: canon, BodyCanonicalization: canon, HeaderKeys: exampleKeys}
113 if mod != nil {
114 mod(&o)
115 }
116 var b bytes.Buffer
117 if err := dkim.Sign(&b, strings.NewReader(msg), &o); err != nil {
118 t.Fatal(err)
119 }
120 return b.String()
121}
122
123func dkimSetup(t *testing.T) (*fixture, *fakeDNS) {
124 t.Helper()
125 f := setup(t)
126 dns := &fakeDNS{}
127 f.p.LookupTXT = dns.lookup
128 f.p.Cfg.Mail.Inbound.RequireDKIM = true
129 for _, a := range []string{"bob@cleberg.net", "bob@mail.example.test", "bob@user.github.io"} {
130 if err := f.st.AddEmail(f.bob, a, "admin", true); err != nil {
131 t.Fatal(err)
132 }
133 }
134 return f, dns
135}
136
137func TestDKIM(t *testing.T) {
138 type tc struct {
139 name, from, domain, selector string
140 key crypto.Signer
141 canon dkim.Canonicalization
142 mod func(*dkim.SignOptions)
143 after func(string) string // applied to the signed message
144 reason string // "" posts
145 retry bool
146 }
147 var relaxed, simple dkim.Canonicalization = dkim.CanonicalizationRelaxed, dkim.CanonicalizationSimple
148 for _, c := range []tc{
149 {name: "rsa relaxed", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed},
150 {name: "rsa simple", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: simple},
151 {name: "ed25519 relaxed", from: "bob@example.test", domain: "example.test", selector: "ed", key: edKey, canon: relaxed},
152 {name: "ed25519 simple", from: "bob@example.test", domain: "example.test", selector: "ed", key: edKey, canon: simple},
153 {name: "the shape Migadu sends", from: "bob@cleberg.net", domain: "cleberg.net", selector: "key1", key: rsaKey, canon: simple,
154 after: func(m string) string {
155 if !strings.Contains(m, "d=cleberg.net;") || !strings.Contains(m, "s=key1;") || !strings.Contains(m, "c=simple/simple;") ||
156 !strings.Contains(m, "h=from:to:subject:date:message-id:mime-version:content-type;") || !strings.Contains(m, "a=rsa-sha256;") {
157 panic("signature not in the expected shape:\n" + m)
158 }
159 return m
160 }},
161 {name: "signing domain a subdomain of From's", from: "bob@example.test", domain: "mail.example.test", selector: "rsa", key: rsaKey, canon: relaxed},
162 {name: "From a subdomain of the signing domain", from: "bob@mail.example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed},
163 {name: "body altered", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
164 after: func(m string) string { return m + "appended\r\n" }, reason: "body hash did not verify"},
165 {name: "header altered", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
166 after: func(m string) string { return strings.Replace(m, "Subject: Re:", "Subject: Fwd:", 1) }, reason: "signature did not verify"},
167 {name: "From not in h=", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
168 after: func(m string) string { return strings.Replace(m, "h=from:to:", "h=to:", 1) }, reason: "From field not signed"},
169 {name: "misaligned d=", from: "bob@example.test", domain: "attacker.example", selector: "rsa", key: rsaKey, canon: relaxed,
170 reason: "d=attacker.example: d= not aligned"},
171 {name: "public suffix d=", from: "bob@user.github.io", domain: "github.io", selector: "rsa", key: rsaKey, canon: relaxed,
172 reason: "d=github.io: d= not aligned"},
173 {name: "expired x=", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
174 mod: func(o *dkim.SignOptions) { o.Expiration = time.Now().Add(-time.Minute) }, reason: "expired"},
175 {name: "l= refused", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
176 after: func(m string) string { return strings.Replace(m, "DKIM-Signature: ", "DKIM-Signature: l=4; ", 1) },
177 reason: "body length"},
178 {name: "rsa-sha1 refused", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
179 after: func(m string) string { return strings.Replace(m, "a=rsa-sha256", "a=rsa-sha1", 1) }, reason: "too weak"},
180 {name: "512-bit key refused", from: "bob@example.test", domain: "example.test", selector: "short", key: rsaKey, canon: relaxed,
181 reason: "too short"},
182 {name: "no key", from: "bob@example.test", domain: "example.test", selector: "gone", key: rsaKey, canon: relaxed,
183 reason: "no key for signature"},
184 {name: "second From field", from: "bob@example.test", domain: "example.test", selector: "rsa", key: rsaKey, canon: relaxed,
185 after: func(m string) string { return "From: bob@example.test\r\n" + m }, reason: "more than one From field"},
186 {name: "DNS temporary failure", from: "bob@example.test", domain: "example.test", selector: "temp", key: rsaKey, canon: relaxed,
187 reason: "key lookup failed", retry: true},
188 {name: "DNS timeout", from: "bob@example.test", domain: "example.test", selector: "timeout", key: rsaKey, canon: relaxed,
189 reason: "key lookup failed", retry: true},
190 } {
191 t.Run(c.name, func(t *testing.T) {
192 f, _ := dkimSetup(t)
193 m := signMsg(t, f.messageAs(t, f.bob, c.from, "<d@x>", "", "hi"), c.domain, c.selector, c.key, c.canon, c.mod)
194 if c.after != nil {
195 m = c.after(m)
196 }
197 res := f.p.Handle([]byte(m))
198 if c.reason == "" {
199 if !res.Posted {
200 t.Fatalf("not posted: %+v", res)
201 }
202 return
203 }
204 if res.Posted || res.Retry != c.retry || !strings.Contains(res.Reason, c.reason) {
205 t.Fatalf("result %+v, want reason %q retry %v", res, c.reason, c.retry)
206 }
207 audit := f.refusalReasons(t)
208 if c.retry {
209 if audit != "" {
210 t.Fatalf("a retry was audited:\n%s", audit)
211 }
212 return
213 }
214 if !strings.Contains(audit, c.reason) {
215 t.Fatalf("refusal not audited:\n%s", audit)
216 }
217 if strings.Contains(audit, "appended") || strings.Contains(audit, `"hi`) {
218 t.Fatalf("audit carries content:\n%s", audit)
219 }
220 })
221 }
222}
223
224func TestDKIMNoSignature(t *testing.T) {
225 f, _ := dkimSetup(t)
226 res := f.p.Handle([]byte(f.message(t, "bob@example.test", "hi")))
227 if res.Posted || res.Retry || !strings.Contains(res.Reason, "no DKIM-Signature") {
228 t.Fatalf("result %+v", res)
229 }
230}
231
232func TestDKIMFutureTime(t *testing.T) {
233 f, _ := dkimSetup(t)
234 m := signMsg(t, f.message(t, "bob@example.test", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
235 f.p.Now = func() time.Time { return time.Now().Add(-time.Hour) }
236 if res := f.p.Handle([]byte(m)); res.Posted || !strings.Contains(res.Reason, "dated in the future") {
237 t.Fatalf("result %+v", res)
238 }
239}
240
241// Only the first maxSignatures signatures are checked.
242func TestDKIMTooManySignatures(t *testing.T) {
243 for _, bad := range []int{maxSignatures - 1, maxSignatures} {
244 f, _ := dkimSetup(t)
245 msg := f.messageAs(t, f.bob, "bob@example.test", "<many@x>", "", "hi")
246 m := signMsg(t, msg, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
247 for i := 0; i < bad; i++ {
248 s := signMsg(t, msg, "attacker.example", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
249 m = strings.TrimSuffix(s, msg) + m
250 }
251 res := f.p.Handle([]byte(m))
252 if want := bad < maxSignatures; res.Posted != want {
253 t.Fatalf("%d misaligned signatures first: posted %v, want %v (%+v)", bad, res.Posted, want, res)
254 }
255 }
256}
257
258// With both set, either passing is enough.
259func TestDKIMOrAuthenticationResults(t *testing.T) {
260 f, _ := dkimSetup(t)
261 f.p.Cfg.Mail.Inbound.TrustedAuthservID = "mx.example.net"
262 signed := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<one@x>", "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
263 if res := f.p.Handle([]byte(signed)); !res.Posted {
264 t.Fatalf("DKIM pass, no Authentication-Results: %+v", res)
265 }
266 ar := "Authentication-Results: mx.example.net; dmarc=pass header.from=example.test\r\n"
267 if res := f.p.Handle([]byte(f.messageAs(t, f.bob, "bob@example.test", "<two@x>", ar, "hi"))); !res.Posted {
268 t.Fatalf("Authentication-Results pass, no signature: %+v", res)
269 }
270 res := f.p.Handle([]byte(f.messageAs(t, f.bob, "bob@example.test", "<three@x>", "", "hi")))
271 if res.Posted || !strings.Contains(res.Reason, "no Authentication-Results") || !strings.Contains(res.Reason, "no DKIM-Signature") {
272 t.Fatalf("neither: %+v", res)
273 }
274}
275
276func TestDKIMKeyCache(t *testing.T) {
277 f, dns := dkimSetup(t)
278 for _, id := range []string{"<c1@x>", "<c2@x>"} {
279 m := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", id, "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
280 if res := f.p.Handle([]byte(m)); !res.Posted {
281 t.Fatalf("%s: %+v", id, res)
282 }
283 }
284 if dns.lookups != 1 {
285 t.Fatalf("%d lookups, want 1", dns.lookups)
286 }
287 f.p.Now = func() time.Time { return time.Now().Add(keyCacheTTL + time.Minute) }
288 f.p.lookupKey("rsa._domainkey.example.test")
289 if dns.lookups != 2 {
290 t.Fatalf("%d lookups after the TTL, want 2", dns.lookups)
291 }
292}
293
294func TestKeyCacheBounded(t *testing.T) {
295 var c keyCache
296 now := time.Now()
297 for i := 0; i < keyCacheSize*2; i++ {
298 c.put(strings.Repeat("x", i+1), nil, now)
299 }
300 if len(c.m) > keyCacheSize {
301 t.Fatalf("%d entries", len(c.m))
302 }
303}
304
305// A temporary DNS failure leaves the message unseen for the next poll.
306func TestDKIMRetryInDrain(t *testing.T) {
307 f, _ := dkimSetup(t)
308 m := signMsg(t, f.message(t, "bob@example.test", "hi"), "example.test", "temp", rsaKey, dkim.CanonicalizationRelaxed, nil)
309 mb := &fakeMailbox{seen: map[uint32]bool{}, msgs: map[uint32][]byte{1: []byte(m)}}
310 if err := f.p.Drain(mb); err != nil {
311 t.Fatal(err)
312 }
313 if mb.seen[1] || f.p.tries[1] != 1 {
314 t.Fatalf("seen %v tries %d", mb.seen[1], f.p.tries[1])
315 }
316}
317
318func TestLookupKeyErrors(t *testing.T) {
319 p := &Processor{LookupTXT: (&fakeDNS{}).lookup}
320 var de *net.DNSError
321 if _, err := p.lookupKey("gone._domainkey.x"); !errors.As(err, &de) || !de.IsNotFound || de.Temporary() {
322 t.Fatalf("not found: %v", err)
323 }
324 for _, sel := range []string{"temp", "timeout"} {
325 if _, err := p.lookupKey(sel + "._domainkey.x"); !errors.As(err, &de) || !de.Temporary() {
326 t.Fatalf("%s: %v", sel, err)
327 }
328 }
329}
330
331// "From : x" is a field net/mail files under "From " and the dkim
332// package under "From". mallory, at the same provider domain as bob,
333// signs her own From, rewrites it as "From : ..." (relaxed
334// canonicalization still verifies it) and adds "From: bob" above:
335// net/mail reads bob as the sender, the signature covers mallory.
336func TestDKIMFromWithSpaceBeforeColon(t *testing.T) {
337 f, _ := dkimSetup(t)
338 m := signMsg(t, f.messageAs(t, f.bob, "mallory@example.test", "<poc@x>", "", "hi"), "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
339 m = strings.Replace(m, "From: Someone <mallory@example.test>", "From: Someone <bob@example.test>\r\nFrom : Someone <mallory@example.test>", 1)
340 res := f.p.Handle([]byte(m))
341 if res.Posted || !strings.Contains(res.Reason, "malformed header field name") {
342 t.Fatalf("result %+v", res)
343 }
344 if !strings.Contains(f.refusalReasons(t), "malformed header field name") {
345 t.Fatal("refusal not audited")
346 }
347}
348
349func replyAddr(t *testing.T, f *fixture) string {
350 return mailreply.Address(replyBase, f.token(t, f.bob))
351}
352
353func TestDKIMTokenBinding(t *testing.T) {
354 var relaxed dkim.Canonicalization = dkim.CanonicalizationRelaxed
355 for _, c := range []struct {
356 name string
357 build func(t *testing.T, f *fixture) string
358 reason string
359 }{
360 {"reply address in Delivered-To only", func(t *testing.T, f *fixture) string {
361 m := f.messageAs(t, f.bob, "bob@example.test", "<b1@x>", "Delivered-To: "+replyAddr(t, f)+"\r\n", "hi")
362 m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
363 return signMsg(t, m, "example.test", "rsa", rsaKey, relaxed, nil)
364 }, "reply address not in To or Cc"},
365 {"To not in h=", func(t *testing.T, f *fixture) string {
366 return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b2@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed,
367 func(o *dkim.SignOptions) { o.HeaderKeys = []string{"from", "subject", "message-id", "content-type"} })
368 }, "to not in h="},
369 {"replayed with the reply address added in an unsigned Cc", func(t *testing.T, f *fixture) string {
370 m := f.messageAs(t, f.bob, "bob@example.test", "<b3@x>", "", "hi")
371 m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
372 m = signMsg(t, m, "example.test", "rsa", rsaKey, relaxed, nil)
373 return "Cc: " + replyAddr(t, f) + "\r\n" + m
374 }, "cc not in h="},
375 {"replayed with the To replaced", func(t *testing.T, f *fixture) string {
376 m := f.messageAs(t, f.bob, "bob@example.test", "<b4@x>", "", "hi")
377 m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
378 m = signMsg(t, m, "example.test", "rsa", rsaKey, relaxed, nil)
379 return strings.Replace(m, "To: friend@example.test", "To: "+replyAddr(t, f), 1)
380 }, "signature did not verify"},
381 {"second To field", func(t *testing.T, f *fixture) string {
382 m := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b5@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed, nil)
383 return "To: other@example.test\r\n" + m
384 }, "more than one to field"},
385 {"Message-ID not in h=", func(t *testing.T, f *fixture) string {
386 return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b6@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed,
387 func(o *dkim.SignOptions) { o.HeaderKeys = []string{"from", "to", "subject", "content-type"} })
388 }, "message-id not in h="},
389 {"Content-Type not in h=", func(t *testing.T, f *fixture) string {
390 return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b7@x>", "", "hi"), "example.test", "rsa", rsaKey, relaxed,
391 func(o *dkim.SignOptions) { o.HeaderKeys = []string{"from", "to", "subject", "message-id"} })
392 }, "content-type not in h="},
393 {"unsigned quoted-printable Content-Transfer-Encoding", func(t *testing.T, f *fixture) string {
394 return signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<b8@x>", "Content-Transfer-Encoding: quoted-printable\r\n", "hi"),
395 "example.test", "rsa", rsaKey, relaxed, nil)
396 }, "content-transfer-encoding not in h= and not 7bit"},
397 } {
398 t.Run(c.name, func(t *testing.T) {
399 f, _ := dkimSetup(t)
400 res := f.p.Handle([]byte(c.build(t, f)))
401 if res.Posted || res.Retry || !strings.Contains(res.Reason, c.reason) {
402 t.Fatalf("result %+v, want %q", res, c.reason)
403 }
404 if !strings.Contains(f.refusalReasons(t), c.reason) {
405 t.Fatal("refusal not audited")
406 }
407 })
408 }
409}
410
411// Content-Transfer-Encoding in h= passes when the message has one, and
412// a reply address in a signed Cc passes.
413func TestDKIMSignedCTEAndCc(t *testing.T) {
414 f, _ := dkimSetup(t)
415 keys := append([]string{"content-transfer-encoding"}, exampleKeys...)
416 m := signMsg(t, f.messageAs(t, f.bob, "bob@example.test", "<p1@x>", "Content-Transfer-Encoding: 7bit\r\n", "hi"),
417 "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, func(o *dkim.SignOptions) { o.HeaderKeys = keys })
418 if res := f.p.Handle([]byte(m)); !res.Posted {
419 t.Fatalf("CTE signed: %+v", res)
420 }
421 m = f.messageAs(t, f.bob, "bob@example.test", "<p2@x>", "", "hi")
422 m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test\r\nCc: "+replyAddr(t, f), 1)
423 m = signMsg(t, m, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed,
424 func(o *dkim.SignOptions) { o.HeaderKeys = append([]string{"cc"}, exampleKeys...) })
425 if res := f.p.Handle([]byte(m)); !res.Posted {
426 t.Fatalf("signed Cc: %+v", res)
427 }
428}
429
430// With only trusted_authserv_id set, the reply address may still come
431// from Delivered-To.
432func TestAuthservTokenFromDeliveredTo(t *testing.T) {
433 f := setup(t)
434 f.p.Cfg.Mail.Inbound.TrustedAuthservID = "mx.example.net"
435 m := f.messageAs(t, f.bob, "bob@example.test", "<ar@x>",
436 "Authentication-Results: mx.example.net; dmarc=pass header.from=example.test\r\nDelivered-To: "+replyAddr(t, f)+"\r\n", "hi")
437 m = strings.Replace(m, "To: gitbay <"+replyAddr(t, f)+">", "To: friend@example.test", 1)
438 if res := f.p.Handle([]byte(m)); !res.Posted {
439 t.Fatalf("result %+v", res)
440 }
441}
442
443// A copy of a signed message posts once, whatever unsigned fields or
444// signatures were changed on the way.
445func TestDKIMDedupeBySignature(t *testing.T) {
446 f, _ := dkimSetup(t)
447 m := f.messageAs(t, f.bob, "bob@example.test", "<gone@x>", "", "hi")
448 m = strings.Replace(m, "Message-ID: <gone@x>\r\n", "", 1)
449 m = signMsg(t, m, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil)
450 if res := f.p.Handle([]byte(m)); !res.Posted {
451 t.Fatalf("first: %+v", res)
452 }
453 if res := f.p.Handle([]byte("X-Resent: 1\r\n" + m)); res.Posted || !strings.Contains(res.Reason, "already posted") {
454 t.Fatalf("copy with an unsigned field added: %+v", res)
455 }
456
457 msg := f.messageAs(t, f.bob, "bob@example.test", "<dual@x>", "", "hi")
458 rsaSig := strings.TrimSuffix(signMsg(t, msg, "example.test", "rsa", rsaKey, dkim.CanonicalizationRelaxed, nil), msg)
459 edSigned := signMsg(t, msg, "example.test", "ed", edKey, dkim.CanonicalizationRelaxed, nil)
460 if res := f.p.Handle([]byte(rsaSig + edSigned)); !res.Posted {
461 t.Fatalf("dual-signed: %+v", res)
462 }
463 if res := f.p.Handle([]byte(edSigned)); res.Posted || !strings.Contains(res.Reason, "already posted") {
464 t.Fatalf("copy with one signature stripped: %+v", res)
465 }
466 if n := len(f.comments(t)); n != 2 {
467 t.Fatalf("%d comments", n)
468 }
469}
470
471func TestParseRawHeader(t *testing.T) {
472 for _, c := range []struct{ raw, reason string }{
473 {"From: a@b\r\nTo: c@d\r\n\r\nbody", ""},
474 {"From: a@b\n Subject-ish continuation\nTo: c@d\n\nbody", ""},
475 {"From : a@b\r\n\r\n", "malformed header field name"},
476 {"From\t: a@b\r\n\r\n", "malformed header field name"},
477 {"Fr\xc3\xb6m: a@b\r\nFrom: a@b\r\n\r\n", "malformed header field name"},
478 {" From: a@b\r\n\r\n", "malformed header"},
479 {"From: a@b\r\nnot a field\r\n\r\n", "malformed header"},
480 {"From: a@b\r\n: x\r\n\r\n", "malformed header"},
481 {"To: c@d\r\n\r\n", "no From field"},
482 {"From: a@b\r\nfROM: c@d\r\n\r\n", "more than one From field"},
483 {"From: a@b\r\nMessage-Id: 1\r\nMESSAGE-ID: 2\r\n\r\n", "more than one message-id field"},
484 {"From: a@b\r\n\r\nFrom : in the body is fine\r\n", ""},
485 } {
486 if _, got := parseRawHeader([]byte(c.raw)); got != c.reason {
487 t.Errorf("%q: %q, want %q", c.raw, got, c.reason)
488 }
489 }
490 h, _ := parseRawHeader([]byte("DKIM-Signature: v=1; b=ab\r\n cd ;d=x\r\nFrom: a@b\r\ndkim-signature: b= ef\r\n\r\n"))
491 if len(h.dkimB) != 2 || h.dkimB[0] != "abcd" || h.dkimB[1] != "ef" {
492 t.Fatalf("dkimB = %q", h.dkimB)
493 }
494 if h, _ := parseRawHeader([]byte("From: a@b\r\nContent-Transfer-Encoding:\r\n Quoted-Printable \r\n\r\n")); h.cte != "quoted-printable" {
495 t.Fatalf("cte = %q", h.cte)
496 }
497}
498
499// The shape Thunderbird sends through Migadu: Content-Transfer-Encoding
500// outside h=. An identity encoding posts; one that changes the decoded
501// body, added unsigned, is refused.
502func TestDKIMUnsignedCTE(t *testing.T) {
503 for _, c := range []struct {
504 cte string
505 post bool
506 }{{"7bit", true}, {" 8BIT ", true}, {"binary", true}, {"quoted-printable", false}, {"base64", false}} {
507 f, _ := dkimSetup(t)
508 msg := "From: Bob <bob@example.test>\r\nTo: " + replyAddr(t, f) + "\r\nSubject: Re: [alice/app] #1: title\r\n" +
509 "Date: Tue, 29 Sep 2026 12:00:00 -0500\r\nMessage-ID: <tb-" + strings.TrimSpace(c.cte) + "@example.test>\r\nMIME-Version: 1.0\r\n" +
510 "Content-Type: text/plain; charset=UTF-8; format=flowed\r\nContent-Transfer-Encoding:" + c.cte + "\r\n\r\nThunderbird reply.\r\n"
511 m := signMsg(t, msg, "example.test", "rsa", rsaKey, dkim.CanonicalizationSimple, nil)
512 if !strings.Contains(m, "a=rsa-sha256;") || !strings.Contains(m, "c=simple/simple;") || !strings.Contains(m, "d=example.test;") ||
513 !strings.Contains(m, "h=from:to:subject:date:message-id:mime-version:content-type;") {
514 t.Fatalf("signature not in the expected shape:\n%s", m)
515 }
516 res := f.p.Handle([]byte(m))
517 if res.Posted != c.post {
518 t.Fatalf("CTE %q: posted %v, want %v (%+v)", c.cte, res.Posted, c.post, res)
519 }
520 if !c.post && !strings.Contains(res.Reason, "content-transfer-encoding not in h=") {
521 t.Fatalf("CTE %q: reason %q", c.cte, res.Reason)
522 }
523 }
524}
internal/mailin/fuzz_test.go +37 −1
@@ -4,9 +4,12 @@ import (
4 "bytes" 4 "bytes"
5 "net/mail" 5 "net/mail"
6 "net/textproto" 6 "net/textproto"
7 "strings"
7 "testing" 8 "testing"
8 "time" 9 "time"
9 10
11 "github.com/emersion/go-msgauth/dkim"
12
10 "gitbay.org/gitbay/internal/mailreply" 13 "gitbay.org/gitbay/internal/mailreply"
11) 14)
12 15
@@ -18,12 +21,13 @@ func FuzzReply(f *testing.F) {
18 f.Add([]byte("Subject: x\r\n\r\nOn Mon wrote:\r\n> a\r\n-- \r\nsig\r\n")) 21 f.Add([]byte("Subject: x\r\n\r\nOn Mon wrote:\r\n> a\r\n-- \r\nsig\r\n"))
19 key := [][]byte{[]byte("0123456789abcdef0123456789abcdef")} 22 key := [][]byte{[]byte("0123456789abcdef0123456789abcdef")}
20 f.Fuzz(func(t *testing.T, raw []byte) { 23 f.Fuzz(func(t *testing.T, raw []byte) {
24 parseRawHeader(raw)
21 msg, err := mail.ReadMessage(bytes.NewReader(raw)) 25 msg, err := mail.ReadMessage(bytes.NewReader(raw))
22 if err != nil { 26 if err != nil {
23 return 27 return
24 } 28 }
25 automatic(msg.Header) 29 automatic(msg.Header)
26 if tok := findToken(msg.Header, "reply@x.example"); tok != "" { 30 if tok, _ := findToken(msg.Header, "reply@x.example", recipientFields); tok != "" {
27 mailreply.Verify(key, tok, fuzzNow) 31 mailreply.Verify(key, tok, fuzzNow)
28 } 32 }
29 if s, err := textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16); err == nil { 33 if s, err := textBody(textproto.MIMEHeader(msg.Header), msg.Body, 1<<16); err == nil {
@@ -33,3 +37,35 @@ func FuzzReply(f *testing.F) {
33} 37}
34 38
35var fuzzNow = time.Date(2026, 9, 29, 0, 0, 0, 0, time.UTC) 39var fuzzNow = time.Date(2026, 9, 29, 0, 0, 0, 0, time.UTC)
40
41// FuzzDKIM runs the DKIM check, the dkim package's signature and key
42// record parsing included, on arbitrary messages: no input panics.
43func FuzzDKIM(f *testing.F) {
44 msg := "From: bob@example.test\r\nTo: x@y\r\nSubject: s\r\n\r\nhi\r\n"
45 for _, canon := range []dkim.Canonicalization{dkim.CanonicalizationSimple, dkim.CanonicalizationRelaxed} {
46 var b bytes.Buffer
47 o := dkim.SignOptions{Domain: "example.test", Selector: "rsa", Signer: rsaKey,
48 HeaderCanonicalization: canon, BodyCanonicalization: canon}
49 if err := dkim.Sign(&b, strings.NewReader(msg), &o); err != nil {
50 f.Fatal(err)
51 }
52 f.Add(b.Bytes())
53 }
54 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"))
55 f.Fuzz(func(t *testing.T, raw []byte) {
56 msg, err := mail.ReadMessage(bytes.NewReader(raw))
57 if err != nil {
58 return
59 }
60 from, err := msg.Header.AddressList("From")
61 if err != nil || len(from) != 1 {
62 return
63 }
64 rh, reason := parseRawHeader(raw)
65 if reason != "" {
66 return
67 }
68 p := &Processor{LookupTXT: (&fakeDNS{}).lookup}
69 p.dkimVerified(raw, rh, from[0].Address, "to")
70 })
71}
internal/mailin/header.go added +102
@@ -0,0 +1,102 @@
1package mailin
2
3import (
4 "bytes"
5 "strings"
6)
7
8// rawHeader is what the checks read from the header section as
9// fetched, before net/mail or the dkim package interpret it.
10type rawHeader struct {
11 count map[string]int // field name, lower case, to occurrences
12 dkimB []string // b= of each DKIM-Signature, in order, whitespace removed
13 cte string // Content-Transfer-Encoding, unfolded, trimmed, lower case
14}
15
16// single names the fields a reply may carry at most once; From must
17// appear exactly once.
18var single = []string{"from", "to", "cc", "message-id", "content-type", "content-transfer-encoding"}
19
20// parseRawHeader reads the header section of raw. A field name must be
21// RFC 5322 ftext (printable US-ASCII other than ":"), so "From : x",
22// which net/mail files under another name than the dkim package does,
23// is refused rather than read two ways. It returns the refusal's
24// reason, or "".
25func parseRawHeader(raw []byte) (rawHeader, string) {
26 h := rawHeader{count: map[string]int{}}
27 var dkimVals []string
28 cur := -1 // index in dkimVals of the DKIM-Signature being continued
29 inCTE := false
30 for len(raw) > 0 {
31 line := raw
32 if i := bytes.IndexByte(raw, '\n'); i >= 0 {
33 line, raw = raw[:i], raw[i+1:]
34 } else {
35 raw = nil
36 }
37 line = bytes.TrimSuffix(line, []byte("\r"))
38 if len(line) == 0 {
39 break
40 }
41 if line[0] == ' ' || line[0] == '\t' {
42 if len(h.count) == 0 {
43 return h, "malformed header"
44 }
45 if cur >= 0 {
46 dkimVals[cur] += string(line)
47 }
48 if inCTE {
49 h.cte += string(line)
50 }
51 continue
52 }
53 name, value, ok := bytes.Cut(line, []byte(":"))
54 if !ok || len(name) == 0 {
55 return h, "malformed header"
56 }
57 for _, c := range name {
58 if c < 33 || c > 126 {
59 return h, "malformed header field name"
60 }
61 }
62 n := strings.ToLower(string(name))
63 h.count[n]++
64 cur = -1
65 inCTE = n == "content-transfer-encoding"
66 if inCTE {
67 h.cte = string(value)
68 }
69 if n == "dkim-signature" {
70 dkimVals = append(dkimVals, string(value))
71 cur = len(dkimVals) - 1
72 }
73 }
74 h.cte = strings.ToLower(strings.TrimSpace(h.cte))
75 for _, v := range dkimVals {
76 h.dkimB = append(h.dkimB, tagB(v))
77 }
78 if n := h.count["from"]; n != 1 {
79 if n == 0 {
80 return h, "no From field"
81 }
82 return h, "more than one From field"
83 }
84 for _, n := range single[1:] {
85 if h.count[n] > 1 {
86 return h, "more than one " + n + " field"
87 }
88 }
89 return h, ""
90}
91
92// tagB returns the b= tag of a DKIM-Signature value with whitespace
93// removed, or "".
94func tagB(v string) string {
95 for _, t := range strings.Split(v, ";") {
96 k, val, ok := strings.Cut(t, "=")
97 if ok && strings.TrimSpace(k) == "b" {
98 return strings.Join(strings.Fields(val), "")
99 }
100 }
101 return ""
102}
internal/mailin/mailin.go +86 −19
@@ -48,7 +48,11 @@ type Processor struct {
48 St *store.Store 48 St *store.Store
49 Cfg config.Config 49 Cfg config.Config
50 Now func() time.Time 50 Now func() time.Time
51 // LookupTXT resolves DKIM selector keys; nil is the system
52 // resolver.
53 LookupTXT LookupTXT
51 54
55 keys keyCache
52 tries map[uint32]int 56 tries map[uint32]int
53 // A window of refusal rows, bounded because anyone can send mail 57 // A window of refusal rows, bounded because anyone can send mail
54 // to the mailbox. 58 // to the mailbox.
@@ -136,6 +140,10 @@ func (p *Processor) Handle(raw []byte) Result {
136 if len(bytes.TrimSpace(raw)) == 0 { 140 if len(bytes.TrimSpace(raw)) == 0 {
137 return p.refuse(0, "", "empty message") 141 return p.refuse(0, "", "empty message")
138 } 142 }
143 rh, reason := parseRawHeader(raw)
144 if reason != "" {
145 return p.refuse(0, "", reason)
146 }
139 msg, err := mail.ReadMessage(bytes.NewReader(raw)) 147 msg, err := mail.ReadMessage(bytes.NewReader(raw))
140 if err != nil { 148 if err != nil {
141 return p.refuse(0, "", "unreadable message") 149 return p.refuse(0, "", "unreadable message")
@@ -148,7 +156,7 @@ func (p *Processor) Handle(raw []byte) Result {
148 return p.refuse(0, msgID, "automatic reply") 156 return p.refuse(0, msgID, "automatic reply")
149 } 157 }
150 in := p.Cfg.Mail.Inbound 158 in := p.Cfg.Mail.Inbound
151 token := findToken(msg.Header, in.ReplyAddress) 159 token, _ := findToken(msg.Header, in.ReplyAddress, recipientFields)
152 if token == "" { 160 if token == "" {
153 return p.refuse(0, msgID, "not addressed to a reply address") 161 return p.refuse(0, msgID, "not addressed to a reply address")
154 } 162 }
@@ -197,10 +205,12 @@ func (p *Processor) Handle(raw []byte) Result {
197 if !ok { 205 if !ok {
198 return p.refuse(u.ID, msgID, "From is not a verified address of the account") 206 return p.refuse(u.ID, msgID, "From is not a verified address of the account")
199 } 207 }
200 if id := p.Cfg.Mail.Inbound.TrustedAuthservID; id != "" { 208 sigIDs, res := p.authenticate(raw, rh, msg.Header, from[0].Address, token)
201 if reason := authenticated(msg.Header, id, from[0].Address); reason != "" { 209 if res != nil {
202 return p.refuse(u.ID, msgID, reason) 210 if res.Retry {
211 return *res
203 } 212 }
213 return p.refuse(u.ID, msgID, res.Reason)
204 } 214 }
205 if on, err := p.St.ReplyEnabled(u.ID); err != nil { 215 if on, err := p.St.ReplyEnabled(u.ID); err != nil {
206 return Result{Retry: true, Reason: err.Error()} 216 return Result{Retry: true, Reason: err.Error()}
@@ -243,13 +253,24 @@ func (p *Processor) Handle(raw []byte) Result {
243 sum := sha256.Sum256(raw) 253 sum := sha256.Sum256(raw)
244 id = "sha256:" + hex.EncodeToString(sum[:]) 254 id = "sha256:" + hex.EncodeToString(sum[:])
245 } 255 }
246 key := fmt.Sprintf("%d/%s/%d/%d/%s", u.ID, target.Kind, target.RepoID, target.Number, id) 256 // Each passing DKIM signature is claimed too, so a copy of a signed
247 claimed, err := p.St.ClaimMailReply(key) 257 // message is not posted again under another Message-ID or with
248 if err != nil { 258 // unsigned fields changed.
249 return Result{Retry: true, Reason: err.Error()} 259 var claims []string
250 } 260 for _, id := range append([]string{id}, sigIDs...) {
251 if !claimed { 261 claims = append(claims, fmt.Sprintf("%d/%s/%d/%d/%s", u.ID, target.Kind, target.RepoID, target.Number, id))
252 return p.refuse(u.ID, msgID, "already posted") 262 }
263 for n, k := range claims {
264 claimed, err := p.St.ClaimMailReply(k)
265 if err != nil || !claimed {
266 for _, k := range claims[:n] {
267 p.St.ReleaseMailReply(k)
268 }
269 if err != nil {
270 return Result{Retry: true, Reason: err.Error()}
271 }
272 return p.refuse(u.ID, msgID, "already posted")
273 }
253 } 274 }
254 275
255 var stdout, stderr bytes.Buffer 276 var stdout, stderr bytes.Buffer
@@ -261,8 +282,10 @@ func (p *Processor) Handle(raw []byte) Result {
261 if code == protocol.ExitOK { 282 if code == protocol.ExitOK {
262 return Result{Posted: true} 283 return Result{Posted: true}
263 } 284 }
264 p.St.ReleaseMailReply(key) 285 for _, k := range claims {
265 reason := strings.TrimSpace(stderr.String()) 286 p.St.ReleaseMailReply(k)
287 }
288 reason = strings.TrimSpace(stderr.String())
266 if code == protocol.ExitFailure { 289 if code == protocol.ExitFailure {
267 return Result{Retry: true, Reason: reason} 290 return Result{Retry: true, Reason: reason}
268 } 291 }
@@ -271,6 +294,47 @@ func (p *Processor) Handle(raw []byte) Result {
271 return p.refuse(u.ID, msgID, "comment refused: "+reason) 294 return p.refuse(u.ID, msgID, "comment refused: "+reason)
272} 295}
273 296
297// authenticate checks that the mail host or the sender's domain vouches
298// for From: an Authentication-Results pass from trusted_authserv_id, or
299// a DKIM signature that verifies here with require_dkim. When both are
300// set either is enough. A DKIM pass also needs the reply address in a
301// signed To or Cc; an Authentication-Results pass takes it from any
302// recipient field. It returns nil when From is authenticated or neither
303// is set, and the unaudited refusal or retry otherwise; sigIDs are the
304// passing DKIM signatures when DKIM authenticated the reply.
305func (p *Processor) authenticate(raw []byte, rh rawHeader, h mail.Header, from, token string) (sigIDs []string, res *Result) {
306 in := p.Cfg.Mail.Inbound
307 var reasons []string
308 if id := in.TrustedAuthservID; id != "" {
309 reason := authenticated(h, id, from)
310 if reason == "" {
311 return nil, nil
312 }
313 reasons = append(reasons, reason)
314 }
315 if in.RequireDKIM {
316 // The reply address must be in a field the signature covers,
317 // or a signed message could be redirected to any token.
318 tok, field := findToken(h, in.ReplyAddress, []string{"To", "Cc"})
319 if tok != token {
320 reasons = append(reasons, "DKIM: reply address not in To or Cc")
321 } else {
322 ids, reason, retry := p.dkimVerified(raw, rh, from, field)
323 if reason == "" {
324 return ids, nil
325 }
326 if retry {
327 return nil, &Result{Retry: true, Reason: reason}
328 }
329 reasons = append(reasons, reason)
330 }
331 }
332 if len(reasons) == 0 {
333 return nil, nil
334 }
335 return nil, &Result{Reason: strings.Join(reasons, "; ")}
336}
337
274// createdAfter refuses when the row was created after the token was 338// createdAfter refuses when the row was created after the token was
275// minted: a later account or repository that took a freed id. Created 339// minted: a later account or repository that took a freed id. Created
276// times are compared to the second, the token's precision. 340// times are compared to the second, the token's precision.
@@ -334,10 +398,13 @@ func automatic(h mail.Header) bool {
334 return h.Get("X-Autoreply") != "" || h.Get("X-Autorespond") != "" 398 return h.Get("X-Autoreply") != "" || h.Get("X-Autorespond") != ""
335} 399}
336 400
337// findToken returns the reply token from the first recipient header 401// recipientFields are where a reply address is looked for, in order.
338// that carries one. 402var recipientFields = []string{"Delivered-To", "X-Original-To", "Envelope-To", "To", "Cc"}
339func findToken(h mail.Header, base string) string { 403
340 for _, name := range []string{"Delivered-To", "X-Original-To", "Envelope-To", "To", "Cc"} { 404// findToken returns the reply token from the first of names that
405// carries one, and that field's name in lower case.
406func findToken(h mail.Header, base string, names []string) (token, field string) {
407 for _, name := range names {
341 for _, v := range h[textproto.CanonicalMIMEHeaderKey(name)] { 408 for _, v := range h[textproto.CanonicalMIMEHeaderKey(name)] {
342 addrs, err := mail.ParseAddressList(v) 409 addrs, err := mail.ParseAddressList(v)
343 if err != nil { 410 if err != nil {
@@ -345,12 +412,12 @@ func findToken(h mail.Header, base string) string {
345 } 412 }
346 for _, a := range addrs { 413 for _, a := range addrs {
347 if tok, ok := mailreply.TokenFrom(base, a.Address); ok { 414 if tok, ok := mailreply.TokenFrom(base, a.Address); ok {
348 return tok 415 return tok, strings.ToLower(name)
349 } 416 }
350 } 417 }
351 } 418 }
352 } 419 }
353 return "" 420 return "", ""
354} 421}
355 422
356// Poller reads the configured mailbox every poll interval. 423// Poller reads the configured mailbox every poll interval.