Commit 14e8eb66f6

14e8eb66f6a4499b2b41d17d7054ea36f9bf2801

parent: 3d82ea0efe

Verified · cmc ci/build: success ci/test: success

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

wiki, changelog: require_dkim

Admin documents require_dkim, recommended on, which fields its
signature must cover, the header name rules, that either it or
trusted_authserv_id suffices, and why gitbay.org uses it; Threat-Model
the DKIM check, token binding, replay and its limits; Deployment the
DNS lookup among outbound connections.

Closes #307

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
163163mailbox = "INBOX" # default
164164poll_interval = "1m" # default; at least 10s
165165reply_address = "reply@gitbay.example"
166require_dkim = true # recommended; see below
166167trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Results id
167168#+end_src
168169
@@ -192,9 +193,48 @@ trusted_authserv_id = "mx.example.org" # the mail host's Authentication-Result
192193 server that sends more than about 11 MiB, or more than a thousand
193194 untagged responses, for one command has its connection closed; one
194195 poll handles at most ten thousand messages.
195- =trusted_authserv_id= is required on any instance reachable from the
196 internet. It names the authserv-id the mail host writes at the start
197 of its =Authentication-Results= header (Gmail's is =mx.google.com=).
196- Whatever the settings, a message is refused when a header field name
197 is not RFC 5322 =ftext= (=From : x=, a space or a non-ASCII byte in a
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=).
198238 With it set, a reply is posted only when the topmost header with that
199239 id shows =dmarc=pass= with =header.from= equal to the From domain, or
200240 =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
205245 claiming the same id are the sender's and are not read. This is only safe when the mail host
206246 removes incoming =Authentication-Results= headers that claim its id,
207247 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
209 mail inbound check= repeats it: without it, =From= is whatever the
210 sender wrote. Mail between two addresses at the same host may carry
211 no =Authentication-Results= at all: at Migadu, mail from another
248 relying on it. With neither this nor =require_dkim= set, the daemon
249 logs a warning at start and =admin mail inbound check= repeats it:
250 =From= is then whatever the sender wrote. Mail between two addresses
251 at the same host may carry no =Authentication-Results= at all: at
252 Migadu, mail from another
212253 Migadu-hosted domain is delivered through its outbound path and gets
213 none, so setting the id refuses every reply from such users.
214 gitbay.org runs without it for that reason until gitbayd verifies
215 DKIM itself (#307).
254 none, so setting the id alone refuses every reply from such users.
255 That mail does carry a DKIM signature aligned with =From= (for
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.
216259- =gitbay admin mail inbound check= logs in, opens the mailbox
217 read-only (EXAMINE) and reports the message and unseen counts, so a
218 check never marks a reply seen before the poller reads it. With
219 inbound off it says so and exits 0. Poll failures are logged as
220 =mail reply: poll failed= with the server and the IMAP error.
260 read-only (EXAMINE) and reports the message and unseen counts and how
261 =From= is authenticated (=require_dkim=, =trusted_authserv_id=), so a
262 check never marks a reply seen before the poller reads it. It warns
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.
221266
222267A reply is posted when all of these hold, checked when it is read:
223268
@@ -230,7 +275,8 @@ A reply is posted when all of these hold, checked when it is read:
230275 by a delete and reused is not the account the token named. The same
231276 holds for the repository.
2322774. =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.
2342805. The message has a =text/plain= part (HTML-only mail is refused, not
235281 converted), and what is left after quoted text and the signature
236282 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.
6262| 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=) |
6363| APNs | queued push | yes, HTTP/2 | provider token signed with the operator's .p8 key |
6464| 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=) |
6566| 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=) |
6667| 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=) |
6768| 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:
124124 mail host echoes into its header (a quoted MAIL FROM local part, a
125125 reason) cannot read as a result; =FuzzAuthResults= checks that. That rests on
126126 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,
128 and a leaked token plus a forged From posts. The Admin page calls it
129 required for any exposed instance.
127 §5); Gmail, Fastmail and Migadu do. With =require_dkim= set, gitbayd
128 verifies the message's DKIM signatures itself, on the bytes as
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.
130162- *Reused ids.* Account and repository ids are reused after a hard
131163 delete. A reply is refused when the account or repository was
132164 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
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
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
727* v1.39.0 — 2026-09-29
828
929Suggested changes, split diffs, reactions, saved