Commit a13f124bf6
Verified · cmc
Layout: unified · split
cmd/gitbayd/main.go +2 −2
| @@ -213,8 +213,8 @@ func serveCmd() *cobra.Command { | ||
| 213 | 213 | if _, err := in.Password(); err != nil { |
| 214 | 214 | return err |
| 215 | 215 | } |
| 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") | |
| 218 | 218 | } |
| 219 | 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/ | ||
| 18 | 18 | go test -run xxx -fuzz FuzzTokenizeNoPanic -fuzztime 10s ./internal/protocol/ |
| 19 | 19 | go test -run xxx -fuzz FuzzReply -fuzztime 10s ./internal/mailin/ |
| 20 | 20 | go test -run xxx -fuzz FuzzAuthResults -fuzztime 10s ./internal/mailin/ |
| 21 | go test -run xxx -fuzz FuzzDKIM -fuzztime 10s ./internal/mailin/ | |
| 21 | 22 | go test -run xxx -fuzz FuzzReadResponse -fuzztime 10s ./internal/imapc/ |
| 22 | 23 | |
| 23 | 24 | echo "== all clear ==" |
go.mod +1
| @@ -7,6 +7,7 @@ require ( | ||
| 7 | 7 | github.com/BurntSushi/toml v1.6.0 |
| 8 | 8 | github.com/ProtonMail/go-crypto v1.5.2 |
| 9 | 9 | github.com/alecthomas/chroma/v2 v2.27.0 |
| 10 | github.com/emersion/go-msgauth v0.7.0 | |
| 10 | 11 | github.com/microcosm-cc/bluemonday v1.0.27 |
| 11 | 12 | github.com/niklasfasching/go-org v1.9.1 |
| 12 | 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 | ||
| 30 | 30 | github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU= |
| 31 | 31 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| 32 | 32 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| 33 | github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc= | |
| 34 | github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck= | |
| 33 | 35 | github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= |
| 34 | 36 | github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= |
| 35 | 37 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
internal/config/config.go +10
| @@ -302,6 +302,16 @@ type MailInbound struct { | ||
| 302 | 302 | // pass, or an aligned DKIM pass, in the topmost such header. Only |
| 303 | 303 | // safe when the mail host removes incoming headers claiming its id. |
| 304 | 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. | |
| 313 | func (m MailInbound) Authenticated() bool { | |
| 314 | return m.TrustedAuthservID != "" || m.RequireDKIM | |
| 305 | 315 | } |
| 306 | 316 | |
| 307 | 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 | 423 | if in.Addr() != "imap.example:993" || in.MailboxName() != "INBOX" || in.Poll() != DefaultInboundPoll { |
| 424 | 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 | 433 | in.TLS = "starttls" |
| 427 | 434 | if in.Addr() != "imap.example:143" { |
| 428 | 435 | t.Fatalf("starttls default port: %q", in.Addr()) |
internal/control/adminmail.go +11 −4
| @@ -17,7 +17,7 @@ func init() { | ||
| 17 | 17 | ReadOnly: true, Run: runAdminMailInboundCheck}) |
| 18 | 18 | } |
| 19 | 19 | |
| 20 | const unauthenticatedWarning = "trusted_authserv_id is unset: a reply's From is not checked against the mail host's DMARC and DKIM results" | |
| 20 | const unauthenticatedWarning = "require_dkim and trusted_authserv_id are unset: a reply's From is not authenticated" | |
| 21 | 21 | |
| 22 | 22 | // runAdminMailInboundCheck logs in to the [mail.inbound] mailbox and |
| 23 | 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 | 36 | Mailbox string `json:"mailbox,omitempty"` |
| 37 | 37 | Messages int `json:"messages"` |
| 38 | 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 | 45 | if !in.Enabled { |
| 42 | 46 | return c.emit(out{}, func(w io.Writer) { |
| @@ -52,8 +56,9 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int { | ||
| 52 | 56 | if err != nil { |
| 53 | 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)} | |
| 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() { | |
| 57 | 62 | d.Warning = unauthenticatedWarning |
| 58 | 63 | fmt.Fprintln(c.Stderr, "warning: "+d.Warning) |
| 59 | 64 | } |
| @@ -63,6 +68,8 @@ func runAdminMailInboundCheck(c *Ctx, args []string) int { | ||
| 63 | 68 | "mailbox", d.Mailbox, |
| 64 | 69 | "messages", fmt.Sprintf("%d", d.Messages), |
| 65 | 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 +164
| @@ -0,0 +1,164 @@ | ||
| 1 | package mailin | |
| 2 | ||
| 3 | import ( | |
| 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 | ||
| 16 | const ( | |
| 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. | |
| 29 | type LookupTXT func(ctx context.Context, name string) ([]string, error) | |
| 30 | ||
| 31 | type 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. | |
| 38 | type keyCache struct { | |
| 39 | mu sync.Mutex | |
| 40 | m map[string]cachedKey | |
| 41 | } | |
| 42 | ||
| 43 | func (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 | ||
| 53 | func (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. | |
| 80 | func (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. | |
| 109 | func (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 | ||
| 157 | func 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 @@ | ||
| 1 | package mailin | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 23 | const rsaPEM = `-----BEGIN PRIVATE KEY----- | |
| 24 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC6i925olmivu5U | |
| 25 | iNMbgSLK4SEZNq4TIPQYPwJ3fHHFa+6V3jtD/2HllkZCrRlBAvra7H8q7TfoCj2K | |
| 26 | lN7hLGkawZM0x9qhxn+AUKuTTL3XeRdl3HQejfuuQvhAQkBU53lF2v0mqzVAEz/k | |
| 27 | cxcNT068yWeCT4GWyT4/A1yy1lfTzyZXrKNcdEDQ5ah8M47kaEYeeRAza19roV3F | |
| 28 | StAggPG/ORC64JXQkwbIEOgNNUYp7hUUWen+kKd5qsuoxaYaWGJ/cEHeEFzsaZN2 | |
| 29 | fA//qFAt3jwfbQvnpPzp1c6txkiDnYJgHXp5gRcdDuZ8q85bmeqZf1OBCR2d7Ery | |
| 30 | q1L1sFdRAgMBAAECggEADoc2DW8HbBVSmmLNjibQftxpp30KsZKvb/P4TTXz5lwx | |
| 31 | iJp2IyWQikDZ1/eDL/z7bHFetgkjgX7KrDBL6116EgthW4r1DARZibS+qAoh/tX/ | |
| 32 | bH9uy7JjF38/tkFyoSol17rmXEyZKRRWtYQBF5hFmY5V8WAfx46EuoOYhJUM4gHt | |
| 33 | 3hN6xqvGEjLTgb1xLf08+ntgTzPW3QSiE3A0b0nyRbu8t8PjP0DoABdORuI16hsi | |
| 34 | 8V1wUoz1iTKGtV1a98moSxyWWC0udBp+tdhZJ+yun5zLqd/cALvXsl8Y7cIIuyLa | |
| 35 | B/mLTkM1SX9swZ06tSa4vRd9fPQz0J5zbgo8Mf3FwQKBgQDH7SuSZ3YpTMhoAmz/ | |
| 36 | V6UVN4NNkYkHqNEB3bv0VRG4bPtLXJH9vyhchtwHQMsKTwbqgsFiUON2VwZ0/pfc | |
| 37 | 0hDooWQHaoVJWRjfZQLD5K42QKQlcDowyvMYT046UViJOSQeTj4IbT0/2EehkHXA | |
| 38 | qCoITU2I6zIfF1Te8yI1wFmdTwKBgQDu3f9rIZq+ihaLrrGMkagofWmVKUzFqVwK | |
| 39 | ckcKXZJ0uQ/ns1er+SITVU3WdOrLsFE+zpE5CH8LG9FIoFhNcCwvzCXV5iACqIi8 | |
| 40 | 4PgkxNDYTo9kMW3yWuWiFcZpLvA7BOCnvW4gmmIofLe1OMBE9F5VquECibeoamPb | |
| 41 | uQgzELnZXwKBgB24BbgXpRryjP/ZDHbQgnuq6tvG/IWk9JzAZ0YktyOhH6HOOu1r | |
| 42 | UwaeDWsOmKAJq0+E7FY/C/D1csJFbjGnEFhkVUg871893VKn40dXYQYzibL/Acdr | |
| 43 | A8PjVg+ZM/4B/np6ywHZqzcoYU2E+dwPo1/kjdgCjkrM3xLdNYKj+y5FAoGAOJaz | |
| 44 | OggeBuHj8XeTbH/dXKpJZyL/oxw6R+dG2TfNyIVHNVcRgBZncjkVVachMNw2gzCg | |
| 45 | yuguYM1YSWJjSQU4EqLEm+YG01pl+ok5gEx4RaZm5g+nwnCyUjHibWzHUNQY/OQt | |
| 46 | wN+SPZE+XFpzgmJ6LsVqxRUnQ2jg+17ciGx/+vUCgYArCRxfa4ecYlZQYTvtbpwY | |
| 47 | pLt6iUht7y69jkwSUTkOn+ZjBDcVWrJwfjt8R9BkMB/2rcwUj4Yo9DcgiWkfHSpM | |
| 48 | W5QuMVb8coft4mC7G37mEoWWdNrc0TLkbfTSr+liNXoWp0QGL7/RQO7HHK+9QVD0 | |
| 49 | FfatkTVO1YuBsyGp9eE5rQ== | |
| 50 | -----END PRIVATE KEY-----` | |
| 51 | ||
| 52 | const edPEM = `-----BEGIN PRIVATE KEY----- | |
| 53 | MC4CAQAwBQYDK2VwBCIEICVufJC+iEzea5y5QlUD39QNmX2n/0c93QCcQrfQH8W8 | |
| 54 | -----END PRIVATE KEY-----` | |
| 55 | ||
| 56 | var rsaKey, edKey = parseKey(rsaPEM), parseKey(edPEM) | |
| 57 | ||
| 58 | func 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 | ||
| 67 | func 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. | |
| 83 | type fakeDNS struct{ lookups int } | |
| 84 | ||
| 85 | func (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 | ||
| 105 | var exampleKeys = []string{"from", "to", "subject", "date", "message-id", "mime-version", "content-type"} | |
| 106 | ||
| 107 | func 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 | ||
| 121 | func 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 | ||
| 135 | func 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 | ||
| 222 | func 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 | ||
| 230 | func 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. | |
| 240 | func 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. | |
| 257 | func 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 | ||
| 274 | func 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 | ||
| 292 | func 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. | |
| 304 | func 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 | ||
| 316 | func 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 ( | ||
| 4 | 4 | "bytes" |
| 5 | 5 | "net/mail" |
| 6 | 6 | "net/textproto" |
| 7 | "strings" | |
| 7 | 8 | "testing" |
| 8 | 9 | "time" |
| 9 | 10 | |
| 11 | "github.com/emersion/go-msgauth/dkim" | |
| 12 | ||
| 10 | 13 | "gitbay.org/gitbay/internal/mailreply" |
| 11 | 14 | ) |
| 12 | 15 | |
| @@ -33,3 +36,31 @@ func FuzzReply(f *testing.F) { | ||
| 33 | 36 | } |
| 34 | 37 | |
| 35 | 38 | var 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. | |
| 42 | func 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 { | ||
| 48 | 48 | St *store.Store |
| 49 | 49 | Cfg config.Config |
| 50 | 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 | 56 | tries map[uint32]int |
| 53 | 57 | // A window of refusal rows, bounded because anyone can send mail |
| 54 | 58 | // to the mailbox. |
| @@ -197,10 +201,11 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 197 | 201 | if !ok { |
| 198 | 202 | return p.refuse(u.ID, msgID, "From is not a verified address of the account") |
| 199 | 203 | } |
| 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 | |
| 203 | 207 | } |
| 208 | return p.refuse(u.ID, msgID, res.Reason) | |
| 204 | 209 | } |
| 205 | 210 | if on, err := p.St.ReplyEnabled(u.ID); err != nil { |
| 206 | 211 | return Result{Retry: true, Reason: err.Error()} |
| @@ -271,6 +276,37 @@ func (p *Processor) Handle(raw []byte) Result { | ||
| 271 | 276 | return p.refuse(u.ID, msgID, "comment refused: "+reason) |
| 272 | 277 | } |
| 273 | 278 | |
| 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. | |
| 284 | func (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 | ||
| 274 | 310 | // createdAfter refuses when the row was created after the token was |
| 275 | 311 | // minted: a later account or repository that took a freed id. Created |
| 276 | 312 | // times are compared to the second, the token's precision. |