1/*2Maddy Mail Server - Composable all-in-one email server.3Copyright © 2019-2020 Max Mazurov <fox.cpp@disroot.org>, Maddy Mail Server contributors45This program is free software: you can redistribute it and/or modify6it under the terms of the GNU General Public License as published by7the Free Software Foundation, either version 3 of the License, or8(at your option) any later version.910This program is distributed in the hope that it will be useful,11but WITHOUT ANY WARRANTY; without even the implied warranty of12MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the13GNU General Public License for more details.1415You should have received a copy of the GNU General Public License16along with this program. If not, see <https://www.gnu.org/licenses/>.17*/1819package dkim2021import (22 "context"23 "crypto"24 "errors"25 "fmt"26 "io"27 "path/filepath"28 "runtime/trace"29 "strings"30 "time"3132 "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)4445const Day = 86400 * time.Second4647var (48 oversignDefault = []string{49 // Directly visible to the user.50 "Subject",51 "To",52 "Cc",53 "From",54 "Date",5556 // Affects body processing.57 "MIME-Version",58 "Content-Type",59 "Content-Transfer-Encoding",6061 // Affects user interaction.62 "Reply-To",63 "In-Reply-To",64 "Message-Id",65 "References",6667 // Provide additional security benefit for OpenPGP.68 "Autocrypt",69 "Openpgp",70 }71 signDefault = []string{72 // Mailing list information. Not oversigned to prevent signature73 // breakage by aliasing MLMs.74 "List-Id",75 "List-Help",76 "List-Unsubscribe",77 "List-Post",78 "List-Owner",79 "List-Archive",8081 // 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",8889 "Sender",90 }9192 hashFuncs = map[string]crypto.Hash{93 "sha256": crypto.SHA256,94 }95)9697type Modifier struct {98 instName string99100 domains []string101 selector string102 signers map[string]crypto.Signer103 oversignHeader []string104 signHeader []string105 headerCanon dkim.Canonicalization106 bodyCanon dkim.Canonicalization107 sigExpiry time.Duration108 hash crypto.Hash109 multipleFromOk bool110 signSubdomains bool111112 log log.Logger113}114115func 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 }121122 if len(inlineArgs) == 0 {123 return m, nil124 }125 if len(inlineArgs) == 1 {126 return nil, errors.New("modify.dkim: at least two arguments required")127 }128129 m.domains = inlineArgs[0 : len(inlineArgs)-1]130 m.selector = inlineArgs[len(inlineArgs)-1]131132 return m, nil133}134135func (m *Modifier) Name() string {136 return "modify.dkim"137}138139func (m *Modifier) InstanceName() string {140 return m.instName141}142143func (m *Modifier) Init(cfg *config.Map) error {144 var (145 hashName string146 keyPathTemplate string147 newKeyAlgo string148 )149150 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)169170 if _, err := cfg.Process(); err != nil {171 return err172 }173174 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 }183184 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 }188189 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 }193194 keyValues := strings.NewReplacer("{domain}", domain, "{selector}", m.selector)195 keyPath := keyValues.Replace(keyPathTemplate)196197 signer, newKey, err := m.loadOrGenerateKey(keyPath, newKeyAlgo)198 if err != nil {199 return err200 }201202 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 }211212 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] = signer217 }218219 return nil220}221222func (m *Modifier) fieldsToSign(h *textproto.Header) []string {223 // Filter out duplicated fields from configs so they224 // will not cause panic() in go-msgauth internals.225 seen := make(map[string]struct{})226227 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 continue231 }232 seen[strings.ToLower(key)] = struct{}{}233234 // 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 continue244 }245 seen[strings.ToLower(key)] = struct{}{}246247 // 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 res253}254255type state struct {256 m *Modifier257 meta *module.MsgMetadata258 from string259 log log.Logger260}261262func (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 }, nil268}269270func (s *state) RewriteSender(ctx context.Context, mailFrom string) (string, error) {271 s.from = mailFrom272 return mailFrom, nil273}274275func (s state) RewriteRcpt(ctx context.Context, rcptTo string) ([]string, error) {276 return []string{rcptTo}, nil277}278279func (s *state) RewriteBody(ctx context.Context, h *textproto.Header, body buffer.Buffer) error {280 defer trace.StartRegion(ctx, "modify.dkim/RewriteBody").End()281282 var domain string283 if s.from != "" {284 var err error285 _, domain, err = address.Split(s.from)286 if err != nil {287 return err288 }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.selector295296 if s.m.signSubdomains {297 topDomain := s.m.domains[0]298 if strings.HasSuffix(domain, "."+topDomain) {299 domain = topDomain300 }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 nil306 }307 keySigner := s.m.signers[normDomain]308 if keySigner == nil {309 s.log.Msg("no key for domain", "domain", normDomain)310 return nil311 }312313 // 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 error317 domain, err = idna.ToASCII(domain)318 if err != nil {319 return nil320 }321322 selector, err = idna.ToASCII(selector)323 if err != nil {324 return nil325 }326 }327328 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 }358359 if err := signer.Close(); err != nil {360 return exterrors.WithFields(err, map[string]interface{}{"modifier": "modify.dkim"})361 }362363 h.AddRaw([]byte(signer.Signature()))364365 s.m.log.DebugMsg("signed", "domain", domain)366367 return nil368}369370func (s state) Close() error {371 return nil372}373374func init() {375 module.Register("modify.dkim", New)376}