maddy

Fork https://github.com/foxcpp/maddy

git clone git://git.lin.moe/go/maddy.git

  1/*
  2Maddy Mail Server - Composable all-in-one email server.
  3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors
  4
  5This program is free software: you can redistribute it and/or modify
  6it under the terms of the GNU General Public License as published by
  7the Free Software Foundation, either version 3 of the License, or
  8(at your option) any later version.
  9
 10This program is distributed in the hope that it will be useful,
 11but WITHOUT ANY WARRANTY; without even the implied warranty of
 12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 13GNU General Public License for more details.
 14
 15You should have received a copy of the GNU General Public License
 16along with this program.  If not, see <https://www.gnu.org/licenses/>.
 17*/
 18
 19package dkim
 20
 21import (
 22	"context"
 23	"crypto"
 24	"errors"
 25	"fmt"
 26	"io"
 27	"path/filepath"
 28	"runtime/trace"
 29	"strings"
 30	"time"
 31
 32	"github.com/emersion/go-message/textproto"
 33	"github.com/emersion/go-msgauth/dkim"
 34	"github.com/foxcpp/maddy/framework/address"
 35	"github.com/foxcpp/maddy/framework/buffer"
 36	"github.com/foxcpp/maddy/framework/config"
 37	"github.com/foxcpp/maddy/framework/dns"
 38	"github.com/foxcpp/maddy/framework/exterrors"
 39	"github.com/foxcpp/maddy/framework/log"
 40	"github.com/foxcpp/maddy/framework/module"
 41	"github.com/foxcpp/maddy/internal/target"
 42	"golang.org/x/net/idna"
 43)
 44
 45const Day = 86400 * time.Second
 46
 47var (
 48	oversignDefault = []string{
 49		// Directly visible to the user.
 50		"Subject",
 51		"To",
 52		"Cc",
 53		"From",
 54		"Date",
 55
 56		// Affects body processing.
 57		"MIME-Version",
 58		"Content-Type",
 59		"Content-Transfer-Encoding",
 60
 61		// Affects user interaction.
 62		"Reply-To",
 63		"In-Reply-To",
 64		"Message-Id",
 65		"References",
 66
 67		// Provide additional security benefit for OpenPGP.
 68		"Autocrypt",
 69		"Openpgp",
 70	}
 71	signDefault = []string{
 72		// Mailing list information. Not oversigned to prevent signature
 73		// breakage by aliasing MLMs.
 74		"List-Id",
 75		"List-Help",
 76		"List-Unsubscribe",
 77		"List-Post",
 78		"List-Owner",
 79		"List-Archive",
 80
 81		// Not oversigned since it can be prepended by intermediate relays.
 82		"Resent-To",
 83		"Resent-Sender",
 84		"Resent-Message-Id",
 85		"Resent-Date",
 86		"Resent-From",
 87		"Resent-Cc",
 88
 89		"Sender",
 90	}
 91
 92	hashFuncs = map[string]crypto.Hash{
 93		"sha256": crypto.SHA256,
 94	}
 95)
 96
 97type Modifier struct {
 98	instName string
 99
100	domains        []string
101	selector       string
102	signers        map[string]crypto.Signer
103	oversignHeader []string
104	signHeader     []string
105	headerCanon    dkim.Canonicalization
106	bodyCanon      dkim.Canonicalization
107	sigExpiry      time.Duration
108	hash           crypto.Hash
109	multipleFromOk bool
110	signSubdomains bool
111
112	log log.Logger
113}
114
115func New(_, instName string, _, inlineArgs []string) (module.Module, error) {
116	m := &Modifier{
117		instName: instName,
118		signers:  map[string]crypto.Signer{},
119		log:      log.Logger{Name: "modify.dkim"},
120	}
121
122	if len(inlineArgs) == 0 {
123		return m, nil
124	}
125	if len(inlineArgs) == 1 {
126		return nil, errors.New("modify.dkim: at least two arguments required")
127	}
128
129	m.domains = inlineArgs[0 : len(inlineArgs)-1]
130	m.selector = inlineArgs[len(inlineArgs)-1]
131
132	return m, nil
133}
134
135func (m *Modifier) Name() string {
136	return "modify.dkim"
137}
138
139func (m *Modifier) InstanceName() string {
140	return m.instName
141}
142
143func (m *Modifier) Init(cfg *config.Map) error {
144	var (
145		hashName        string
146		keyPathTemplate string
147		newKeyAlgo      string
148	)
149
150	cfg.Bool("debug", true, false, &m.log.Debug)
151	cfg.StringList("domains", false, false, m.domains, &m.domains)
152	cfg.String("selector", false, false, m.selector, &m.selector)
153	cfg.String("key_path", false, false, "dkim_keys/{domain}_{selector}.key", &keyPathTemplate)
154	cfg.StringList("oversign_fields", false, false, oversignDefault, &m.oversignHeader)
155	cfg.StringList("sign_fields", false, false, signDefault, &m.signHeader)
156	cfg.Enum("header_canon", false, false,
157		[]string{string(dkim.CanonicalizationRelaxed), string(dkim.CanonicalizationSimple)},
158		dkim.CanonicalizationRelaxed, (*string)(&m.headerCanon))
159	cfg.Enum("body_canon", false, false,
160		[]string{string(dkim.CanonicalizationRelaxed), string(dkim.CanonicalizationSimple)},
161		dkim.CanonicalizationRelaxed, (*string)(&m.bodyCanon))
162	cfg.Duration("sig_expiry", false, false, 5*Day, &m.sigExpiry)
163	cfg.Enum("hash", false, false,
164		[]string{"sha256"}, "sha256", &hashName)
165	cfg.Enum("newkey_algo", false, false,
166		[]string{"rsa4096", "rsa2048", "ed25519"}, "rsa2048", &newKeyAlgo)
167	cfg.Bool("allow_multiple_from", false, false, &m.multipleFromOk)
168	cfg.Bool("sign_subdomains", false, false, &m.signSubdomains)
169
170	if _, err := cfg.Process(); err != nil {
171		return err
172	}
173
174	if len(m.domains) == 0 {
175		return errors.New("sign_domain: at least one domain is needed")
176	}
177	if m.selector == "" {
178		return errors.New("sign_domain: selector is not specified")
179	}
180	if m.signSubdomains && len(m.domains) > 1 {
181		return errors.New("sign_domain: only one domain is supported when sign_subdomains is enabled")
182	}
183
184	m.hash = hashFuncs[hashName]
185	if m.hash == 0 {
186		panic("modify.dkim.Init: Hash function allowed by config matcher but not present in hashFuncs")
187	}
188
189	for _, domain := range m.domains {
190		if _, err := idna.ToASCII(domain); err != nil {
191			m.log.Printf("warning: unable to convert domain %s to A-labels form, non-EAI messages will not be signed: %v", domain, err)
192		}
193
194		keyValues := strings.NewReplacer("{domain}", domain, "{selector}", m.selector)
195		keyPath := keyValues.Replace(keyPathTemplate)
196
197		signer, newKey, err := m.loadOrGenerateKey(keyPath, newKeyAlgo)
198		if err != nil {
199			return err
200		}
201
202		if newKey {
203			dnsPath := keyPath + ".dns"
204			if filepath.Ext(keyPath) == ".key" {
205				dnsPath = keyPath[:len(keyPath)-4] + ".dns"
206			}
207			m.log.Printf("generated a new %s keypair, private key is in %s, TXT record with public key is in %s,\n"+
208				"put its contents into TXT record for %s._domainkey.%s to make signing and verification work",
209				newKeyAlgo, keyPath, dnsPath, m.selector, domain)
210		}
211
212		normDomain, err := dns.ForLookup(domain)
213		if err != nil {
214			return fmt.Errorf("sign_skim: unable to normalize domain %s: %w", domain, err)
215		}
216		m.signers[normDomain] = signer
217	}
218
219	return nil
220}
221
222func (m *Modifier) fieldsToSign(h *textproto.Header) []string {
223	// Filter out duplicated fields from configs so they
224	// will not cause panic() in go-msgauth internals.
225	seen := make(map[string]struct{})
226
227	res := make([]string, 0, len(m.oversignHeader)+len(m.signHeader))
228	for _, key := range m.oversignHeader {
229		if _, ok := seen[strings.ToLower(key)]; ok {
230			continue
231		}
232		seen[strings.ToLower(key)] = struct{}{}
233
234		// Add to signing list once per each key use.
235		for field := h.FieldsByKey(key); field.Next(); {
236			res = append(res, key)
237		}
238		// And once more to "oversign" it.
239		res = append(res, key)
240	}
241	for _, key := range m.signHeader {
242		if _, ok := seen[strings.ToLower(key)]; ok {
243			continue
244		}
245		seen[strings.ToLower(key)] = struct{}{}
246
247		// Add to signing list once per each key use.
248		for field := h.FieldsByKey(key); field.Next(); {
249			res = append(res, key)
250		}
251	}
252	return res
253}
254
255type state struct {
256	m    *Modifier
257	meta *module.MsgMetadata
258	from string
259	log  log.Logger
260}
261
262func (m *Modifier) ModStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.ModifierState, error) {
263	return &state{
264		m:    m,
265		meta: msgMeta,
266		log:  target.DeliveryLogger(m.log, msgMeta),
267	}, nil
268}
269
270func (s *state) RewriteSender(ctx context.Context, mailFrom string) (string, error) {
271	s.from = mailFrom
272	return mailFrom, nil
273}
274
275func (s state) RewriteRcpt(ctx context.Context, rcptTo string) ([]string, error) {
276	return []string{rcptTo}, nil
277}
278
279func (s *state) RewriteBody(ctx context.Context, h *textproto.Header, body buffer.Buffer) error {
280	defer trace.StartRegion(ctx, "modify.dkim/RewriteBody").End()
281
282	var domain string
283	if s.from != "" {
284		var err error
285		_, domain, err = address.Split(s.from)
286		if err != nil {
287			return err
288		}
289	}
290	// Use first key for null return path (<>) and postmaster (<postmaster>)
291	if domain == "" {
292		domain = s.m.domains[0]
293	}
294	selector := s.m.selector
295
296	if s.m.signSubdomains {
297		topDomain := s.m.domains[0]
298		if strings.HasSuffix(domain, "."+topDomain) {
299			domain = topDomain
300		}
301	}
302	normDomain, err := dns.ForLookup(domain)
303	if err != nil {
304		s.log.Error("unable to normalize domain from envelope sender", err, "domain", domain)
305		return nil
306	}
307	keySigner := s.m.signers[normDomain]
308	if keySigner == nil {
309		s.log.Msg("no key for domain", "domain", normDomain)
310		return nil
311	}
312
313	// If the message is non-EAI, we are not allowed to use domains in U-labels,
314	// attempt to convert.
315	if !s.meta.SMTPOpts.UTF8 {
316		var err error
317		domain, err = idna.ToASCII(domain)
318		if err != nil {
319			return nil
320		}
321
322		selector, err = idna.ToASCII(selector)
323		if err != nil {
324			return nil
325		}
326	}
327
328	opts := dkim.SignOptions{
329		Domain:                 domain,
330		Selector:               selector,
331		Identifier:             "@" + domain,
332		Signer:                 keySigner,
333		Hash:                   s.m.hash,
334		HeaderCanonicalization: s.m.headerCanon,
335		BodyCanonicalization:   s.m.bodyCanon,
336		HeaderKeys:             s.m.fieldsToSign(h),
337	}
338	if s.m.sigExpiry != 0 {
339		opts.Expiration = time.Now().Add(s.m.sigExpiry)
340	}
341	signer, err := dkim.NewSigner(&opts)
342	if err != nil {
343		return exterrors.WithFields(err, map[string]interface{}{"modifier": "modify.dkim"})
344	}
345	if err := textproto.WriteHeader(signer, *h); err != nil {
346		signer.Close()
347		return exterrors.WithFields(err, map[string]interface{}{"modifier": "modify.dkim"})
348	}
349	r, err := body.Open()
350	if err != nil {
351		signer.Close()
352		return exterrors.WithFields(err, map[string]interface{}{"modifier": "modify.dkim"})
353	}
354	if _, err := io.Copy(signer, r); err != nil {
355		signer.Close()
356		return exterrors.WithFields(err, map[string]interface{}{"modifier": "modify.dkim"})
357	}
358
359	if err := signer.Close(); err != nil {
360		return exterrors.WithFields(err, map[string]interface{}{"modifier": "modify.dkim"})
361	}
362
363	h.AddRaw([]byte(signer.Signature()))
364
365	s.m.log.DebugMsg("signed", "domain", domain)
366
367	return nil
368}
369
370func (s state) Close() error {
371	return nil
372}
373
374func init() {
375	module.Register("modify.dkim", New)
376}