diff --git a/Makefile b/Makefile index eb2500d4..82a0ac58 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,7 @@ test: cd irc/caps && go test . && go vet . cd irc/cloaks && go test . && go vet . cd irc/connection_limits && go test . && go vet . + cd irc/email && go test . && go vet . cd irc/history && go test . && go vet . cd irc/isupport && go test . && go vet . cd irc/modes && go test . && go vet . diff --git a/conventional.yaml b/conventional.yaml index 80f7e7ed..02dbfd95 100644 --- a/conventional.yaml +++ b/conventional.yaml @@ -280,16 +280,24 @@ accounts: enabled-callbacks: - none # no verification needed, will instantly register successfully - # example configuration for sending verification emails via a local mail relay + # example configuration for sending verification emails # callbacks: # mailto: - # server: localhost - # port: 25 - # tls: - # enabled: false - # username: "" - # password: "" # sender: "admin@my.network" + # require-tls: true + # helo-domain: "my.network" # defaults to server name if unset + # dkim: + # domain: "my.network" + # selector: "20200229" + # key-file: "dkim.pem" + # # to use an MTA/smarthost instead of sending email directly: + # # mta: + # # server: localhost + # # port: 25 + # # username: "admin" + # # password: "hunter2" + # blacklist-regexes: + # # - ".*@mailinator.com" # throttle account login attempts (to prevent either password guessing, or DoS # attacks on the server aimed at forcing repeated expensive bcrypt computations) diff --git a/go.mod b/go.mod index 79fb737f..56bdeaa2 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/oragono/go-ident v0.0.0-20170110123031-337fed0fd21a github.com/stretchr/testify v1.4.0 // indirect github.com/tidwall/buntdb v1.1.2 + github.com/toorop/go-dkim v0.0.0-20191019073156-897ad64a2eeb golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 golang.org/x/text v0.3.2 gopkg.in/yaml.v2 v2.2.8 diff --git a/go.sum b/go.sum index 3788d575..09a8d189 100644 --- a/go.sum +++ b/go.sum @@ -65,6 +65,8 @@ github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e h1:+NL1GDIUOKxVfbp2K github.com/tidwall/rtree v0.0.0-20180113144539-6cd427091e0e/go.mod h1:/h+UnNGt0IhNNJLkGikcdcJqm66zGD/uJGMRxK/9+Ao= github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 h1:Otn9S136ELckZ3KKDyCkxapfufrqDqwmGjcHfAyXRrE= github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563/go.mod h1:mLqSmt7Dv/CNneF2wfcChfN1rvapyQr01LGKnKex0DQ= +github.com/toorop/go-dkim v0.0.0-20191019073156-897ad64a2eeb h1:ilDZC+k9r67aJqSOalZLtEVLO7Cmmsq5ftfcvLirc24= +github.com/toorop/go-dkim v0.0.0-20191019073156-897ad64a2eeb/go.mod h1:BzWtXXrXzZUvMacR0oF/fbDDgUPO8L36tDMmRAf14ns= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191112222119-e1110fd1c708 h1:pXVtWnwHkrWD9ru3sDxY/qFK/bfc0egRovX91EjWjf4= golang.org/x/crypto v0.0.0-20191112222119-e1110fd1c708/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= diff --git a/irc/accounts.go b/irc/accounts.go index 0626e4b7..2d5ec8eb 100644 --- a/irc/accounts.go +++ b/irc/accounts.go @@ -4,9 +4,9 @@ package irc import ( + "bytes" "encoding/json" "fmt" - "net/smtp" "strconv" "strings" "sync" @@ -16,6 +16,7 @@ import ( "github.com/oragono/oragono/irc/caps" "github.com/oragono/oragono/irc/connection_limits" + "github.com/oragono/oragono/irc/email" "github.com/oragono/oragono/irc/ldap" "github.com/oragono/oragono/irc/passwd" "github.com/oragono/oragono/irc/utils" @@ -483,7 +484,7 @@ func (am *AccountManager) Register(client *Client, account string, callbackNames return err } - code, err := am.dispatchCallback(client, casefoldedAccount, callbackNamespace, callbackValue) + code, err := am.dispatchCallback(client, account, callbackNamespace, callbackValue) if err != nil { am.Unregister(casefoldedAccount, true) return errCallbackFailed @@ -698,17 +699,17 @@ func (am *AccountManager) addRemoveCertfp(account, certfp string, add bool, hasP return err } -func (am *AccountManager) dispatchCallback(client *Client, casefoldedAccount string, callbackNamespace string, callbackValue string) (string, error) { +func (am *AccountManager) dispatchCallback(client *Client, account string, callbackNamespace string, callbackValue string) (string, error) { if callbackNamespace == "*" || callbackNamespace == "none" || callbackNamespace == "admin" { return "", nil } else if callbackNamespace == "mailto" { - return am.dispatchMailtoCallback(client, casefoldedAccount, callbackValue) + return am.dispatchMailtoCallback(client, account, callbackValue) } else { return "", fmt.Errorf("Callback not implemented: %s", callbackNamespace) } } -func (am *AccountManager) dispatchMailtoCallback(client *Client, casefoldedAccount string, callbackValue string) (code string, err error) { +func (am *AccountManager) dispatchMailtoCallback(client *Client, account string, callbackValue string) (code string, err error) { config := am.server.Config().Accounts.Registration.Callbacks.Mailto code = utils.GenerateSecretToken() @@ -716,34 +717,27 @@ func (am *AccountManager) dispatchMailtoCallback(client *Client, casefoldedAccou if subject == "" { subject = fmt.Sprintf(client.t("Verify your account on %s"), am.server.name) } - messageStrings := []string{ - fmt.Sprintf("From: %s\r\n", config.Sender), - fmt.Sprintf("To: %s\r\n", callbackValue), - fmt.Sprintf("Subject: %s\r\n", subject), - "\r\n", // end headers, begin message body - fmt.Sprintf(client.t("Account: %s"), casefoldedAccount) + "\r\n", - fmt.Sprintf(client.t("Verification code: %s"), code) + "\r\n", - "\r\n", - client.t("To verify your account, issue the following command:") + "\r\n", - fmt.Sprintf("/MSG NickServ VERIFY %s %s", casefoldedAccount, code) + "\r\n", - } - var message []byte - for i := 0; i < len(messageStrings); i++ { - message = append(message, []byte(messageStrings[i])...) - } - addr := fmt.Sprintf("%s:%d", config.Server, config.Port) - var auth smtp.Auth - if config.Username != "" && config.Password != "" { - auth = smtp.PlainAuth("", config.Username, config.Password, config.Server) + var message bytes.Buffer + fmt.Fprintf(&message, "From: %s\r\n", config.Sender) + fmt.Fprintf(&message, "To: %s\r\n", callbackValue) + if config.DKIM.Domain != "" { + fmt.Fprintf(&message, "Message-ID: <%s@%s>\r\n", utils.GenerateSecretKey(), config.DKIM.Domain) } + fmt.Fprintf(&message, "Subject: %s\r\n", subject) + message.WriteString("\r\n") // blank line: end headers, begin message body + fmt.Fprintf(&message, client.t("Account: %s"), account) + message.WriteString("\r\n") + fmt.Fprintf(&message, client.t("Verification code: %s"), code) + message.WriteString("\r\n") + message.WriteString("\r\n") + message.WriteString(client.t("To verify your account, issue the following command:")) + message.WriteString("\r\n") + fmt.Fprintf(&message, "/MSG NickServ VERIFY %s %s\r\n", account, code) - // TODO: this will never send the password in plaintext over a nonlocal link, - // but it might send the email in plaintext, regardless of the value of - // config.TLS.InsecureSkipVerify - err = smtp.SendMail(addr, auth, config.Sender, []string{callbackValue}, message) + err = email.SendMail(config, callbackValue, message.Bytes()) if err != nil { - am.server.logger.Error("internal", "Failed to dispatch e-mail", err.Error()) + am.server.logger.Error("internal", "Failed to dispatch e-mail to", callbackValue, err.Error()) } return } diff --git a/irc/config.go b/irc/config.go index 34ab354b..d2c20c58 100644 --- a/irc/config.go +++ b/irc/config.go @@ -24,6 +24,7 @@ import ( "github.com/oragono/oragono/irc/cloaks" "github.com/oragono/oragono/irc/connection_limits" "github.com/oragono/oragono/irc/custime" + "github.com/oragono/oragono/irc/email" "github.com/oragono/oragono/irc/isupport" "github.com/oragono/oragono/irc/languages" "github.com/oragono/oragono/irc/ldap" @@ -290,20 +291,7 @@ type AccountRegistrationConfig struct { EnabledCredentialTypes []string `yaml:"-"` VerifyTimeout custime.Duration `yaml:"verify-timeout"` Callbacks struct { - Mailto struct { - Server string - Port int - TLS struct { - Enabled bool - InsecureSkipVerify bool `yaml:"insecure_skip_verify"` - ServerName string `yaml:"servername"` - } - Username string - Password string - Sender string - VerifyMessageSubject string `yaml:"verify-message-subject"` - VerifyMessage string `yaml:"verify-message"` - } + Mailto email.MailtoConfig } BcryptCost uint `yaml:"bcrypt-cost"` } @@ -975,14 +963,24 @@ func LoadConfig(filename string) (config *Config, err error) { // hardcode this for now config.Accounts.Registration.EnabledCredentialTypes = []string{"passphrase", "certfp"} + mailtoEnabled := false for i, name := range config.Accounts.Registration.EnabledCallbacks { if name == "none" { // we store "none" as "*" internally config.Accounts.Registration.EnabledCallbacks[i] = "*" + } else if name == "mailto" { + mailtoEnabled = true } } sort.Strings(config.Accounts.Registration.EnabledCallbacks) + if mailtoEnabled { + err := config.Accounts.Registration.Callbacks.Mailto.Postprocess(config.Server.Name) + if err != nil { + return nil, err + } + } + config.Accounts.RequireSasl.exemptedNets, err = utils.ParseNetList(config.Accounts.RequireSasl.Exempted) if err != nil { return nil, fmt.Errorf("Could not parse require-sasl exempted nets: %v", err.Error()) diff --git a/irc/email/dkim.go b/irc/email/dkim.go new file mode 100644 index 00000000..c82b4b40 --- /dev/null +++ b/irc/email/dkim.go @@ -0,0 +1,54 @@ +// Copyright (c) 2020 Shivaram Lingamneni +// released under the MIT license + +package email + +import ( + "errors" + dkim "github.com/toorop/go-dkim" + "io/ioutil" +) + +var ( + ErrMissingFields = errors.New("DKIM config is missing fields") +) + +type DKIMConfig struct { + Domain string + Selector string + KeyFile string `yaml:"key-file"` + keyBytes []byte +} + +func (dkim *DKIMConfig) Postprocess() (err error) { + if dkim.Domain != "" { + if dkim.Selector == "" || dkim.KeyFile == "" { + return ErrMissingFields + } + dkim.keyBytes, err = ioutil.ReadFile(dkim.KeyFile) + if err != nil { + return err + } + } + return nil +} + +var defaultOptions = dkim.SigOptions{ + Version: 1, + Canonicalization: "relaxed/relaxed", + Algo: "rsa-sha256", + Headers: []string{"from", "to", "subject", "message-id"}, + BodyLength: 0, + QueryMethods: []string{"dns/txt"}, + AddSignatureTimestamp: true, + SignatureExpireIn: 0, +} + +func DKIMSign(message []byte, dkimConfig DKIMConfig) (result []byte, err error) { + options := defaultOptions + options.PrivateKey = dkimConfig.keyBytes + options.Domain = dkimConfig.Domain + options.Selector = dkimConfig.Selector + err = dkim.Sign(&message, options) + return message, err +} diff --git a/irc/email/email.go b/irc/email/email.go new file mode 100644 index 00000000..212f8fab --- /dev/null +++ b/irc/email/email.go @@ -0,0 +1,124 @@ +// Copyright (c) 2020 Shivaram Lingamneni +// released under the MIT license + +package email + +import ( + "errors" + "fmt" + "net" + "regexp" + "strings" + + "github.com/oragono/oragono/irc/smtp" +) + +var ( + ErrBlacklistedAddress = errors.New("Email address is blacklisted") + ErrInvalidAddress = errors.New("Email address is blacklisted") + ErrNoMXRecord = errors.New("Couldn't resolve MX record") +) + +type MTAConfig struct { + Server string + Port int + Username string + Password string +} + +type MailtoConfig struct { + // legacy config format assumed the use of an MTA/smarthost, + // so server, port, etc. appear directly at top level + // XXX: see https://github.com/go-yaml/yaml/issues/63 + MTAConfig `yaml:",inline"` + Sender string + HeloDomain string `yaml:"helo-domain"` + RequireTLS bool `yaml:"require-tls"` + VerifyMessageSubject string `yaml:"verify-message-subject"` + DKIM DKIMConfig + MTAReal MTAConfig `yaml:"mta"` + BlacklistRegexes []string `yaml:"blacklist-regexes"` + blacklistRegexes []*regexp.Regexp +} + +func (config *MailtoConfig) Postprocess(heloDomain string) (err error) { + if config.Sender == "" { + return errors.New("Invalid mailto sender address") + } + + // check for MTA config fields at top level, + // copy to MTAReal if present + if config.Server != "" && config.MTAReal.Server == "" { + config.MTAReal = config.MTAConfig + } + + if config.HeloDomain == "" { + config.HeloDomain = heloDomain + } + + for _, reg := range config.BlacklistRegexes { + compiled, err := regexp.Compile(fmt.Sprintf("^%s$", reg)) + if err != nil { + return err + } + config.blacklistRegexes = append(config.blacklistRegexes, compiled) + } + + if config.MTAConfig.Server != "" { + // smarthost, nothing more to validate + return nil + } + + return config.DKIM.Postprocess() +} + +// get the preferred MX record hostname, "" on error +func lookupMX(domain string) (server string) { + var minPref uint16 + results, err := net.LookupMX(domain) + if err != nil { + return + } + for _, result := range results { + if minPref == 0 || result.Pref < minPref { + server, minPref = result.Host, result.Pref + } + } + return +} + +func SendMail(config MailtoConfig, recipient string, msg []byte) (err error) { + for _, reg := range config.blacklistRegexes { + if reg.MatchString(recipient) { + return ErrBlacklistedAddress + } + } + + if config.DKIM.Domain != "" { + msg, err = DKIMSign(msg, config.DKIM) + if err != nil { + return + } + } + + var addr string + var auth smtp.Auth + if config.MTAReal.Server != "" { + addr = fmt.Sprintf("%s:%d", config.MTAReal.Server, config.MTAReal.Port) + if config.MTAReal.Username != "" && config.MTAReal.Password != "" { + auth = smtp.PlainAuth("", config.MTAReal.Username, config.MTAReal.Password, config.MTAReal.Server) + } + } else { + idx := strings.IndexByte(recipient, '@') + if idx == -1 { + return ErrInvalidAddress + } + mx := lookupMX(recipient[idx+1:]) + if mx == "" { + return ErrNoMXRecord + } + addr = fmt.Sprintf("%s:smtp", mx) + } + + return smtp.SendMail(addr, auth, config.HeloDomain, config.Sender, []string{recipient}, msg, config.RequireTLS) +} diff --git a/irc/smtp/LICENSE b/irc/smtp/LICENSE new file mode 100644 index 00000000..6a66aea5 --- /dev/null +++ b/irc/smtp/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/irc/smtp/auth.go b/irc/smtp/auth.go new file mode 100644 index 00000000..fd1a472f --- /dev/null +++ b/irc/smtp/auth.go @@ -0,0 +1,110 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package smtp + +import ( + "crypto/hmac" + "crypto/md5" + "errors" + "fmt" +) + +// Auth is implemented by an SMTP authentication mechanism. +type Auth interface { + // Start begins an authentication with a server. + // It returns the name of the authentication protocol + // and optionally data to include in the initial AUTH message + // sent to the server. It can return proto == "" to indicate + // that the authentication should be skipped. + // If it returns a non-nil error, the SMTP client aborts + // the authentication attempt and closes the connection. + Start(server *ServerInfo) (proto string, toServer []byte, err error) + + // Next continues the authentication. The server has just sent + // the fromServer data. If more is true, the server expects a + // response, which Next should return as toServer; otherwise + // Next should return toServer == nil. + // If Next returns a non-nil error, the SMTP client aborts + // the authentication attempt and closes the connection. + Next(fromServer []byte, more bool) (toServer []byte, err error) +} + +// ServerInfo records information about an SMTP server. +type ServerInfo struct { + Name string // SMTP server name + TLS bool // using TLS, with valid certificate for Name + Auth []string // advertised authentication mechanisms +} + +type plainAuth struct { + identity, username, password string + host string +} + +// PlainAuth returns an Auth that implements the PLAIN authentication +// mechanism as defined in RFC 4616. The returned Auth uses the given +// username and password to authenticate to host and act as identity. +// Usually identity should be the empty string, to act as username. +// +// PlainAuth will only send the credentials if the connection is using TLS +// or is connected to localhost. Otherwise authentication will fail with an +// error, without sending the credentials. +func PlainAuth(identity, username, password, host string) Auth { + return &plainAuth{identity, username, password, host} +} + +func isLocalhost(name string) bool { + return name == "localhost" || name == "127.0.0.1" || name == "::1" +} + +func (a *plainAuth) Start(server *ServerInfo) (string, []byte, error) { + // Must have TLS, or else localhost server. + // Note: If TLS is not true, then we can't trust ANYTHING in ServerInfo. + // In particular, it doesn't matter if the server advertises PLAIN auth. + // That might just be the attacker saying + // "it's ok, you can trust me with your password." + if !server.TLS && !isLocalhost(server.Name) { + return "", nil, errors.New("unencrypted connection") + } + if server.Name != a.host { + return "", nil, errors.New("wrong host name") + } + resp := []byte(a.identity + "\x00" + a.username + "\x00" + a.password) + return "PLAIN", resp, nil +} + +func (a *plainAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + // We've already sent everything. + return nil, errors.New("unexpected server challenge") + } + return nil, nil +} + +type cramMD5Auth struct { + username, secret string +} + +// CRAMMD5Auth returns an Auth that implements the CRAM-MD5 authentication +// mechanism as defined in RFC 2195. +// The returned Auth uses the given username and secret to authenticate +// to the server using the challenge-response mechanism. +func CRAMMD5Auth(username, secret string) Auth { + return &cramMD5Auth{username, secret} +} + +func (a *cramMD5Auth) Start(server *ServerInfo) (string, []byte, error) { + return "CRAM-MD5", nil, nil +} + +func (a *cramMD5Auth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + d := hmac.New(md5.New, []byte(a.secret)) + d.Write(fromServer) + s := make([]byte, 0, d.Size()) + return []byte(fmt.Sprintf("%s %x", a.username, d.Sum(s))), nil + } + return nil, nil +} diff --git a/irc/smtp/smtp.go b/irc/smtp/smtp.go new file mode 100644 index 00000000..80b76992 --- /dev/null +++ b/irc/smtp/smtp.go @@ -0,0 +1,433 @@ +// Copyright 2010 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC 5321. +// It also implements the following extensions: +// 8BITMIME RFC 1652 +// AUTH RFC 2554 +// STARTTLS RFC 3207 +// Additional extensions may be handled by clients. +// +// The smtp package is frozen and is not accepting new features. +// Some external packages provide more functionality. See: +// +// https://godoc.org/?q=smtp +package smtp + +import ( + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/textproto" + "strings" +) + +// A Client represents a client connection to an SMTP server. +type Client struct { + // Text is the textproto.Conn used by the Client. It is exported to allow for + // clients to add extensions. + Text *textproto.Conn + // keep a reference to the connection so it can be used to create a TLS + // connection later + conn net.Conn + // whether the Client is using TLS + tls bool + serverName string + // map of supported extensions + ext map[string]string + // supported auth mechanisms + auth []string + localName string // the name to use in HELO/EHLO + didHello bool // whether we've said HELO/EHLO + helloError error // the error from the hello +} + +// Dial returns a new Client connected to an SMTP server at addr. +// The addr must include a port, as in "mail.example.com:smtp". +func Dial(addr string) (*Client, error) { + conn, err := net.Dial("tcp", addr) + if err != nil { + return nil, err + } + host, _, _ := net.SplitHostPort(addr) + return NewClient(conn, host) +} + +// NewClient returns a new Client using an existing connection and host as a +// server name to be used when authenticating. +func NewClient(conn net.Conn, host string) (*Client, error) { + text := textproto.NewConn(conn) + _, _, err := text.ReadResponse(220) + if err != nil { + text.Close() + return nil, err + } + c := &Client{Text: text, conn: conn, serverName: host, localName: "localhost"} + _, c.tls = conn.(*tls.Conn) + return c, nil +} + +// Close closes the connection. +func (c *Client) Close() error { + return c.Text.Close() +} + +// hello runs a hello exchange if needed. +func (c *Client) hello() error { + if !c.didHello { + c.didHello = true + err := c.ehlo() + if err != nil { + c.helloError = c.helo() + } + } + return c.helloError +} + +// Hello sends a HELO or EHLO to the server as the given host name. +// Calling this method is only necessary if the client needs control +// over the host name used. The client will introduce itself as "localhost" +// automatically otherwise. If Hello is called, it must be called before +// any of the other methods. +func (c *Client) Hello(localName string) error { + if err := validateLine(localName); err != nil { + return err + } + if c.didHello { + return errors.New("smtp: Hello called after other methods") + } + c.localName = localName + return c.hello() +} + +// cmd is a convenience function that sends a command and returns the response +func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) { + id, err := c.Text.Cmd(format, args...) + if err != nil { + return 0, "", err + } + c.Text.StartResponse(id) + defer c.Text.EndResponse(id) + code, msg, err := c.Text.ReadResponse(expectCode) + return code, msg, err +} + +// helo sends the HELO greeting to the server. It should be used only when the +// server does not support ehlo. +func (c *Client) helo() error { + c.ext = nil + _, _, err := c.cmd(250, "HELO %s", c.localName) + return err +} + +// ehlo sends the EHLO (extended hello) greeting to the server. It +// should be the preferred greeting for servers that support it. +func (c *Client) ehlo() error { + _, msg, err := c.cmd(250, "EHLO %s", c.localName) + if err != nil { + return err + } + ext := make(map[string]string) + extList := strings.Split(msg, "\n") + if len(extList) > 1 { + extList = extList[1:] + for _, line := range extList { + args := strings.SplitN(line, " ", 2) + if len(args) > 1 { + ext[args[0]] = args[1] + } else { + ext[args[0]] = "" + } + } + } + if mechs, ok := ext["AUTH"]; ok { + c.auth = strings.Split(mechs, " ") + } + c.ext = ext + return err +} + +// StartTLS sends the STARTTLS command and encrypts all further communication. +// Only servers that advertise the STARTTLS extension support this function. +func (c *Client) StartTLS(config *tls.Config) error { + if err := c.hello(); err != nil { + return err + } + _, _, err := c.cmd(220, "STARTTLS") + if err != nil { + return err + } + c.conn = tls.Client(c.conn, config) + c.Text = textproto.NewConn(c.conn) + c.tls = true + return c.ehlo() +} + +// TLSConnectionState returns the client's TLS connection state. +// The return values are their zero values if StartTLS did +// not succeed. +func (c *Client) TLSConnectionState() (state tls.ConnectionState, ok bool) { + tc, ok := c.conn.(*tls.Conn) + if !ok { + return + } + return tc.ConnectionState(), true +} + +// Verify checks the validity of an email address on the server. +// If Verify returns nil, the address is valid. A non-nil return +// does not necessarily indicate an invalid address. Many servers +// will not verify addresses for security reasons. +func (c *Client) Verify(addr string) error { + if err := validateLine(addr); err != nil { + return err + } + if err := c.hello(); err != nil { + return err + } + _, _, err := c.cmd(250, "VRFY %s", addr) + return err +} + +// Auth authenticates a client using the provided authentication mechanism. +// A failed authentication closes the connection. +// Only servers that advertise the AUTH extension support this function. +func (c *Client) Auth(a Auth) error { + if err := c.hello(); err != nil { + return err + } + encoding := base64.StdEncoding + mech, resp, err := a.Start(&ServerInfo{c.serverName, c.tls, c.auth}) + if err != nil { + c.Quit() + return err + } + resp64 := make([]byte, encoding.EncodedLen(len(resp))) + encoding.Encode(resp64, resp) + code, msg64, err := c.cmd(0, strings.TrimSpace(fmt.Sprintf("AUTH %s %s", mech, resp64))) + for err == nil { + var msg []byte + switch code { + case 334: + msg, err = encoding.DecodeString(msg64) + case 235: + // the last message isn't base64 because it isn't a challenge + msg = []byte(msg64) + default: + err = &textproto.Error{Code: code, Msg: msg64} + } + if err == nil { + resp, err = a.Next(msg, code == 334) + } + if err != nil { + // abort the AUTH + c.cmd(501, "*") + c.Quit() + break + } + if resp == nil { + break + } + resp64 = make([]byte, encoding.EncodedLen(len(resp))) + encoding.Encode(resp64, resp) + code, msg64, err = c.cmd(0, string(resp64)) + } + return err +} + +// Mail issues a MAIL command to the server using the provided email address. +// If the server supports the 8BITMIME extension, Mail adds the BODY=8BITMIME +// parameter. +// This initiates a mail transaction and is followed by one or more Rcpt calls. +func (c *Client) Mail(from string) error { + if err := validateLine(from); err != nil { + return err + } + if err := c.hello(); err != nil { + return err + } + cmdStr := "MAIL FROM:<%s>" + if c.ext != nil { + if _, ok := c.ext["8BITMIME"]; ok { + cmdStr += " BODY=8BITMIME" + } + } + _, _, err := c.cmd(250, cmdStr, from) + return err +} + +// Rcpt issues a RCPT command to the server using the provided email address. +// A call to Rcpt must be preceded by a call to Mail and may be followed by +// a Data call or another Rcpt call. +func (c *Client) Rcpt(to string) error { + if err := validateLine(to); err != nil { + return err + } + _, _, err := c.cmd(25, "RCPT TO:<%s>", to) + return err +} + +type dataCloser struct { + c *Client + io.WriteCloser +} + +func (d *dataCloser) Close() error { + d.WriteCloser.Close() + _, _, err := d.c.Text.ReadResponse(250) + return err +} + +// Data issues a DATA command to the server and returns a writer that +// can be used to write the mail headers and body. The caller should +// close the writer before calling any more methods on c. A call to +// Data must be preceded by one or more calls to Rcpt. +func (c *Client) Data() (io.WriteCloser, error) { + _, _, err := c.cmd(354, "DATA") + if err != nil { + return nil, err + } + return &dataCloser{c, c.Text.DotWriter()}, nil +} + +var testHookStartTLS func(*tls.Config) // nil, except for tests + +// SendMail connects to the server at addr, switches to TLS if +// possible, authenticates with the optional mechanism a if possible, +// and then sends an email from address from, to addresses to, with +// message msg. +// The addr must include a port, as in "mail.example.com:smtp". +// +// The addresses in the to parameter are the SMTP RCPT addresses. +// +// The msg parameter should be an RFC 822-style email with headers +// first, a blank line, and then the message body. The lines of msg +// should be CRLF terminated. The msg headers should usually include +// fields such as "From", "To", "Subject", and "Cc". Sending "Bcc" +// messages is accomplished by including an email address in the to +// parameter but not including it in the msg headers. +// +// The SendMail function and the net/smtp package are low-level +// mechanisms and provide no support for DKIM signing, MIME +// attachments (see the mime/multipart package), or other mail +// functionality. Higher-level packages exist outside of the standard +// library. +// XXX: modified in Oragono to add `requireTLS` and `heloDomain` arguments +func SendMail(addr string, a Auth, heloDomain string, from string, to []string, msg []byte, requireTLS bool) error { + if err := validateLine(from); err != nil { + return err + } + for _, recp := range to { + if err := validateLine(recp); err != nil { + return err + } + } + c, err := Dial(addr) + if err != nil { + return err + } + defer c.Close() + if err = c.Hello(heloDomain); err != nil { + return err + } + if ok, _ := c.Extension("STARTTLS"); ok { + config := &tls.Config{ServerName: c.serverName} + if testHookStartTLS != nil { + testHookStartTLS(config) + } + if err = c.StartTLS(config); err != nil { + return err + } + } else if requireTLS { + return errors.New("TLS required, but not negotiated") + } + if a != nil && c.ext != nil { + if _, ok := c.ext["AUTH"]; !ok { + return errors.New("smtp: server doesn't support AUTH") + } + if err = c.Auth(a); err != nil { + return err + } + } + if err = c.Mail(from); err != nil { + return err + } + for _, addr := range to { + if err = c.Rcpt(addr); err != nil { + return err + } + } + w, err := c.Data() + if err != nil { + return err + } + _, err = w.Write(msg) + if err != nil { + return err + } + err = w.Close() + if err != nil { + return err + } + return c.Quit() +} + +// Extension reports whether an extension is support by the server. +// The extension name is case-insensitive. If the extension is supported, +// Extension also returns a string that contains any parameters the +// server specifies for the extension. +func (c *Client) Extension(ext string) (bool, string) { + if err := c.hello(); err != nil { + return false, "" + } + if c.ext == nil { + return false, "" + } + ext = strings.ToUpper(ext) + param, ok := c.ext[ext] + return ok, param +} + +// Reset sends the RSET command to the server, aborting the current mail +// transaction. +func (c *Client) Reset() error { + if err := c.hello(); err != nil { + return err + } + _, _, err := c.cmd(250, "RSET") + return err +} + +// Noop sends the NOOP command to the server. It does nothing but check +// that the connection to the server is okay. +func (c *Client) Noop() error { + if err := c.hello(); err != nil { + return err + } + _, _, err := c.cmd(250, "NOOP") + return err +} + +// Quit sends the QUIT command and closes the connection to the server. +func (c *Client) Quit() error { + if err := c.hello(); err != nil { + return err + } + _, _, err := c.cmd(221, "QUIT") + if err != nil { + return err + } + return c.Text.Close() +} + +// validateLine checks to see if a line has CR or LF as per RFC 5321 +func validateLine(line string) error { + if strings.ContainsAny(line, "\n\r") { + return errors.New("smtp: A line must not contain CR or LF") + } + return nil +} diff --git a/oragono.yaml b/oragono.yaml index a33c3819..953f8a8a 100644 --- a/oragono.yaml +++ b/oragono.yaml @@ -301,16 +301,24 @@ accounts: enabled-callbacks: - none # no verification needed, will instantly register successfully - # example configuration for sending verification emails via a local mail relay + # example configuration for sending verification emails # callbacks: # mailto: - # server: localhost - # port: 25 - # tls: - # enabled: false - # username: "" - # password: "" # sender: "admin@my.network" + # require-tls: true + # helo-domain: "my.network" # defaults to server name if unset + # dkim: + # domain: "my.network" + # selector: "20200229" + # key-file: "dkim.pem" + # # to use an MTA/smarthost instead of sending email directly: + # # mta: + # # server: localhost + # # port: 25 + # # username: "admin" + # # password: "hunter2" + # blacklist-regexes: + # # - ".*@mailinator.com" # throttle account login attempts (to prevent either password guessing, or DoS # attacks on the server aimed at forcing repeated expensive bcrypt computations) diff --git a/vendor/github.com/toorop/go-dkim/.gitignore b/vendor/github.com/toorop/go-dkim/.gitignore new file mode 100644 index 00000000..daf913b1 --- /dev/null +++ b/vendor/github.com/toorop/go-dkim/.gitignore @@ -0,0 +1,24 @@ +# Compiled Object files, Static and Dynamic libs (Shared Objects) +*.o +*.a +*.so + +# Folders +_obj +_test + +# Architecture specific extensions/prefixes +*.[568vq] +[568vq].out + +*.cgo1.go +*.cgo2.c +_cgo_defun.c +_cgo_gotypes.go +_cgo_export.* + +_testmain.go + +*.exe +*.test +*.prof diff --git a/vendor/github.com/toorop/go-dkim/LICENSE b/vendor/github.com/toorop/go-dkim/LICENSE new file mode 100644 index 00000000..f1afb74f --- /dev/null +++ b/vendor/github.com/toorop/go-dkim/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2015 Stéphane Depierrepont + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/vendor/github.com/toorop/go-dkim/README.md b/vendor/github.com/toorop/go-dkim/README.md new file mode 100644 index 00000000..49567395 --- /dev/null +++ b/vendor/github.com/toorop/go-dkim/README.md @@ -0,0 +1,56 @@ +# go-dkim +DKIM package for Golang + +[![GoDoc](https://godoc.org/github.com/toorop/go-dkim?status.svg)](https://godoc.org/github.com/toorop/go-dkim) + +## Getting started + +### Install +``` + go get github.com/toorop/go-dkim +``` +Warning: you need to use Go 1.4.2-master or 1.4.3 (when it will be available) +see https://github.com/golang/go/issues/10482 fro more info. + +### Sign email + +```go +import ( + dkim "github.com/toorop/go-dkim" +) + +func main(){ + // email is the email to sign (byte slice) + // privateKey the private key (pem encoded, byte slice ) + options := dkim.NewSigOptions() + options.PrivateKey = privateKey + options.Domain = "mydomain.tld" + options.Selector = "myselector" + options.SignatureExpireIn = 3600 + options.BodyLength = 50 + options.Headers = []string{"from", "date", "mime-version", "received", "received"} + options.AddSignatureTimestamp = true + options.Canonicalization = "relaxed/relaxed" + err := dkim.Sign(&email, options) + // handle err.. + + // And... that's it, 'email' is signed ! Amazing© !!! +} +``` + +### Verify +```go +import ( + dkim "github.com/toorop/go-dkim" +) + +func main(){ + // email is the email to verify (byte slice) + status, err := Verify(&email) + // handle status, err (see godoc for status) +} +``` + +## Todo + +- [ ] handle z tag (copied header fields used for diagnostic use) diff --git a/vendor/github.com/toorop/go-dkim/dkim.go b/vendor/github.com/toorop/go-dkim/dkim.go new file mode 100644 index 00000000..87ccea74 --- /dev/null +++ b/vendor/github.com/toorop/go-dkim/dkim.go @@ -0,0 +1,557 @@ +// Package dkim provides tools for signing and verify a email according to RFC 6376 +package dkim + +import ( + "bytes" + "container/list" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "hash" + "regexp" + "strings" + "time" +) + +const ( + CRLF = "\r\n" + TAB = " " + FWS = CRLF + TAB + MaxHeaderLineLength = 70 +) + +type verifyOutput int + +const ( + SUCCESS verifyOutput = 1 + iota + PERMFAIL + TEMPFAIL + NOTSIGNED + TESTINGSUCCESS + TESTINGPERMFAIL + TESTINGTEMPFAIL +) + +// sigOptions represents signing options +type SigOptions struct { + + // DKIM version (default 1) + Version uint + + // Private key used for signing (required) + PrivateKey []byte + + // Domain (required) + Domain string + + // Selector (required) + Selector string + + // The Agent of User IDentifier + Auid string + + // Message canonicalization (plain-text; OPTIONAL, default is + // "simple/simple"). This tag informs the Verifier of the type of + // canonicalization used to prepare the message for signing. + Canonicalization string + + // The algorithm used to generate the signature + //"rsa-sha1" or "rsa-sha256" + Algo string + + // Signed header fields + Headers []string + + // Body length count( if set to 0 this tag is ommited in Dkim header) + BodyLength uint + + // Query Methods used to retrieve the public key + QueryMethods []string + + // Add a signature timestamp + AddSignatureTimestamp bool + + // Time validity of the signature (0=never) + SignatureExpireIn uint64 + + // CopiedHeaderFileds + CopiedHeaderFields []string +} + +// NewSigOptions returns new sigoption with some defaults value +func NewSigOptions() SigOptions { + return SigOptions{ + Version: 1, + Canonicalization: "simple/simple", + Algo: "rsa-sha256", + Headers: []string{"from"}, + BodyLength: 0, + QueryMethods: []string{"dns/txt"}, + AddSignatureTimestamp: true, + SignatureExpireIn: 0, + } +} + +// Sign signs an email +func Sign(email *[]byte, options SigOptions) error { + var privateKey *rsa.PrivateKey + + // PrivateKey + if len(options.PrivateKey) == 0 { + return ErrSignPrivateKeyRequired + } + d, _ := pem.Decode(options.PrivateKey) + if d == nil { + return ErrCandNotParsePrivateKey + } + key, err := x509.ParsePKCS1PrivateKey(d.Bytes) + if err != nil { + return ErrCandNotParsePrivateKey + } + privateKey = key + + // Domain required + if options.Domain == "" { + return ErrSignDomainRequired + } + + // Selector required + if options.Selector == "" { + return ErrSignSelectorRequired + } + + // Canonicalization + options.Canonicalization, err = validateCanonicalization(strings.ToLower(options.Canonicalization)) + if err != nil { + return err + } + + // Algo + options.Algo = strings.ToLower(options.Algo) + if options.Algo != "rsa-sha1" && options.Algo != "rsa-sha256" { + return ErrSignBadAlgo + } + + // Header must contain "from" + hasFrom := false + for i, h := range options.Headers { + h = strings.ToLower(h) + options.Headers[i] = h + if h == "from" { + hasFrom = true + } + } + if !hasFrom { + return ErrSignHeaderShouldContainsFrom + } + + // Normalize + headers, body, err := canonicalize(email, options.Canonicalization, options.Headers) + if err != nil { + return err + } + + signHash := strings.Split(options.Algo, "-") + + // hash body + bodyHash, err := getBodyHash(&body, signHash[1], options.BodyLength) + if err != nil { + return err + } + + // Get dkim header base + dkimHeader := newDkimHeaderBySigOptions(options) + dHeader := dkimHeader.getHeaderBaseForSigning(bodyHash) + + canonicalizations := strings.Split(options.Canonicalization, "/") + dHeaderCanonicalized, err := canonicalizeHeader(dHeader, canonicalizations[0]) + if err != nil { + return err + } + headers = append(headers, []byte(dHeaderCanonicalized)...) + headers = bytes.TrimRight(headers, " \r\n") + + // sign + sig, err := getSignature(&headers, privateKey, signHash[1]) + + // add to DKIM-Header + subh := "" + l := len(subh) + for _, c := range sig { + subh += string(c) + l++ + if l >= MaxHeaderLineLength { + dHeader += subh + FWS + subh = "" + l = 0 + } + } + dHeader += subh + CRLF + *email = append([]byte(dHeader), *email...) + return nil +} + +// Verify verifies an email an return +// state: SUCCESS or PERMFAIL or TEMPFAIL, TESTINGSUCCESS, TESTINGPERMFAIL +// TESTINGTEMPFAIL or NOTSIGNED +// error: if an error occurs during verification +func Verify(email *[]byte, opts ...DNSOpt) (verifyOutput, error) { + // parse email + dkimHeader, err := newDkimHeaderFromEmail(email) + if err != nil { + if err == ErrDkimHeaderNotFound { + return NOTSIGNED, ErrDkimHeaderNotFound + } + return PERMFAIL, err + } + + // we do not set query method because if it's others, validation failed earlier + pubKey, verifyOutputOnError, err := NewPubKeyRespFromDNS(dkimHeader.Selector, dkimHeader.Domain, opts...) + if err != nil { + // fix https://github.com/toorop/go-dkim/issues/1 + //return getVerifyOutput(verifyOutputOnError, err, pubKey.FlagTesting) + return verifyOutputOnError, err + } + + // Normalize + headers, body, err := canonicalize(email, dkimHeader.MessageCanonicalization, dkimHeader.Headers) + if err != nil { + return getVerifyOutput(PERMFAIL, err, pubKey.FlagTesting) + } + sigHash := strings.Split(dkimHeader.Algorithm, "-") + // check if hash algo are compatible + compatible := false + for _, algo := range pubKey.HashAlgo { + if sigHash[1] == algo { + compatible = true + break + } + } + if !compatible { + return getVerifyOutput(PERMFAIL, ErrVerifyInappropriateHashAlgo, pubKey.FlagTesting) + } + + // expired ? + if !dkimHeader.SignatureExpiration.IsZero() && dkimHeader.SignatureExpiration.Second() < time.Now().Second() { + return getVerifyOutput(PERMFAIL, ErrVerifySignatureHasExpired, pubKey.FlagTesting) + + } + + //println("|" + string(body) + "|") + // get body hash + bodyHash, err := getBodyHash(&body, sigHash[1], dkimHeader.BodyLength) + if err != nil { + return getVerifyOutput(PERMFAIL, err, pubKey.FlagTesting) + } + //println(bodyHash) + if bodyHash != dkimHeader.BodyHash { + return getVerifyOutput(PERMFAIL, ErrVerifyBodyHash, pubKey.FlagTesting) + } + + // compute sig + dkimHeaderCano, err := canonicalizeHeader(dkimHeader.RawForSign, strings.Split(dkimHeader.MessageCanonicalization, "/")[0]) + if err != nil { + return getVerifyOutput(TEMPFAIL, err, pubKey.FlagTesting) + } + toSignStr := string(headers) + dkimHeaderCano + toSign := bytes.TrimRight([]byte(toSignStr), " \r\n") + + err = verifySignature(toSign, dkimHeader.SignatureData, &pubKey.PubKey, sigHash[1]) + if err != nil { + return getVerifyOutput(PERMFAIL, err, pubKey.FlagTesting) + } + return SUCCESS, nil +} + +// getVerifyOutput returns output of verify fct according to the testing flag +func getVerifyOutput(status verifyOutput, err error, flagTesting bool) (verifyOutput, error) { + if !flagTesting { + return status, err + } + switch status { + case SUCCESS: + return TESTINGSUCCESS, err + case PERMFAIL: + return TESTINGPERMFAIL, err + case TEMPFAIL: + return TESTINGTEMPFAIL, err + } + // should never happen but compilator sream whithout return + return status, err +} + +// canonicalize returns canonicalized version of header and body +func canonicalize(email *[]byte, cano string, h []string) (headers, body []byte, err error) { + body = []byte{} + rxReduceWS := regexp.MustCompile(`[ \t]+`) + + rawHeaders, rawBody, err := getHeadersBody(email) + if err != nil { + return nil, nil, err + } + + canonicalizations := strings.Split(cano, "/") + + // canonicalyze header + headersList, err := getHeadersList(&rawHeaders) + + // pour chaque header a conserver on traverse tous les headers dispo + // If multi instance of a field we must keep it from the bottom to the top + var match *list.Element + headersToKeepList := list.New() + + for _, headerToKeep := range h { + match = nil + headerToKeepToLower := strings.ToLower(headerToKeep) + for e := headersList.Front(); e != nil; e = e.Next() { + //fmt.Printf("|%s|\n", e.Value.(string)) + t := strings.Split(e.Value.(string), ":") + if strings.ToLower(t[0]) == headerToKeepToLower { + match = e + } + } + if match != nil { + headersToKeepList.PushBack(match.Value.(string) + "\r\n") + headersList.Remove(match) + } + } + + //if canonicalizations[0] == "simple" { + for e := headersToKeepList.Front(); e != nil; e = e.Next() { + cHeader, err := canonicalizeHeader(e.Value.(string), canonicalizations[0]) + if err != nil { + return headers, body, err + } + headers = append(headers, []byte(cHeader)...) + } + // canonicalyze body + if canonicalizations[1] == "simple" { + // simple + // The "simple" body canonicalization algorithm ignores all empty lines + // at the end of the message body. An empty line is a line of zero + // length after removal of the line terminator. If there is no body or + // no trailing CRLF on the message body, a CRLF is added. It makes no + // other changes to the message body. In more formal terms, the + // "simple" body canonicalization algorithm converts "*CRLF" at the end + // of the body to a single "CRLF". + // Note that a completely empty or missing body is canonicalized as a + // single "CRLF"; that is, the canonicalized length will be 2 octets. + body = bytes.TrimRight(rawBody, "\r\n") + body = append(body, []byte{13, 10}...) + } else { + // relaxed + // Ignore all whitespace at the end of lines. Implementations + // MUST NOT remove the CRLF at the end of the line. + // Reduce all sequences of WSP within a line to a single SP + // character. + // Ignore all empty lines at the end of the message body. "Empty + // line" is defined in Section 3.4.3. If the body is non-empty but + // does not end with a CRLF, a CRLF is added. (For email, this is + // only possible when using extensions to SMTP or non-SMTP transport + // mechanisms.) + rawBody = rxReduceWS.ReplaceAll(rawBody, []byte(" ")) + for _, line := range bytes.SplitAfter(rawBody, []byte{10}) { + line = bytes.TrimRight(line, " \r\n") + body = append(body, line...) + body = append(body, []byte{13, 10}...) + } + body = bytes.TrimRight(body, "\r\n") + body = append(body, []byte{13, 10}...) + + } + return +} + +// canonicalizeHeader returns canonicalized version of header +func canonicalizeHeader(header string, algo string) (string, error) { + //rxReduceWS := regexp.MustCompile(`[ \t]+`) + if algo == "simple" { + // The "simple" header canonicalization algorithm does not change header + // fields in any way. Header fields MUST be presented to the signing or + // verification algorithm exactly as they are in the message being + // signed or verified. In particular, header field names MUST NOT be + // case folded and whitespace MUST NOT be changed. + return header, nil + } else if algo == "relaxed" { + // The "relaxed" header canonicalization algorithm MUST apply the + // following steps in order: + + // Convert all header field names (not the header field values) to + // lowercase. For example, convert "SUBJect: AbC" to "subject: AbC". + + // Unfold all header field continuation lines as described in + // [RFC5322]; in particular, lines with terminators embedded in + // continued header field values (that is, CRLF sequences followed by + // WSP) MUST be interpreted without the CRLF. Implementations MUST + // NOT remove the CRLF at the end of the header field value. + + // Convert all sequences of one or more WSP characters to a single SP + // character. WSP characters here include those before and after a + // line folding boundary. + + // Delete all WSP characters at the end of each unfolded header field + // value. + + // Delete any WSP characters remaining before and after the colon + // separating the header field name from the header field value. The + // colon separator MUST be retained. + kv := strings.SplitN(header, ":", 2) + if len(kv) != 2 { + return header, ErrBadMailFormatHeaders + } + k := strings.ToLower(kv[0]) + k = strings.TrimSpace(k) + v := removeFWS(kv[1]) + //v = rxReduceWS.ReplaceAllString(v, " ") + //v = strings.TrimSpace(v) + return k + ":" + v + CRLF, nil + } + return header, ErrSignBadCanonicalization +} + +// getBodyHash return the hash (bas64encoded) of the body +func getBodyHash(body *[]byte, algo string, bodyLength uint) (string, error) { + var h hash.Hash + if algo == "sha1" { + h = sha1.New() + } else { + h = sha256.New() + } + toH := *body + // if l tag (body length) + if bodyLength != 0 { + if uint(len(toH)) < bodyLength { + return "", ErrBadDKimTagLBodyTooShort + } + toH = toH[0:bodyLength] + } + + h.Write(toH) + return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil +} + +// getSignature return signature of toSign using key +func getSignature(toSign *[]byte, key *rsa.PrivateKey, algo string) (string, error) { + var h1 hash.Hash + var h2 crypto.Hash + switch algo { + case "sha1": + h1 = sha1.New() + h2 = crypto.SHA1 + break + case "sha256": + h1 = sha256.New() + h2 = crypto.SHA256 + break + default: + return "", ErrVerifyInappropriateHashAlgo + } + + // sign + h1.Write(*toSign) + sig, err := rsa.SignPKCS1v15(rand.Reader, key, h2, h1.Sum(nil)) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(sig), nil +} + +// verifySignature verify signature from pubkey +func verifySignature(toSign []byte, sig64 string, key *rsa.PublicKey, algo string) error { + var h1 hash.Hash + var h2 crypto.Hash + switch algo { + case "sha1": + h1 = sha1.New() + h2 = crypto.SHA1 + break + case "sha256": + h1 = sha256.New() + h2 = crypto.SHA256 + break + default: + return ErrVerifyInappropriateHashAlgo + } + + h1.Write(toSign) + sig, err := base64.StdEncoding.DecodeString(sig64) + if err != nil { + return err + } + return rsa.VerifyPKCS1v15(key, h2, h1.Sum(nil), sig) +} + +// removeFWS removes all FWS from string +func removeFWS(in string) string { + rxReduceWS := regexp.MustCompile(`[ \t]+`) + out := strings.Replace(in, "\n", "", -1) + out = strings.Replace(out, "\r", "", -1) + out = rxReduceWS.ReplaceAllString(out, " ") + return strings.TrimSpace(out) +} + +// validateCanonicalization validate canonicalization (c flag) +func validateCanonicalization(cano string) (string, error) { + p := strings.Split(cano, "/") + if len(p) > 2 { + return "", ErrSignBadCanonicalization + } + if len(p) == 1 { + cano = cano + "/simple" + } + for _, c := range p { + if c != "simple" && c != "relaxed" { + return "", ErrSignBadCanonicalization + } + } + return cano, nil +} + +// getHeadersList returns headers as list +func getHeadersList(rawHeader *[]byte) (*list.List, error) { + headersList := list.New() + currentHeader := []byte{} + for _, line := range bytes.SplitAfter(*rawHeader, []byte{10}) { + if line[0] == 32 || line[0] == 9 { + if len(currentHeader) == 0 { + return headersList, ErrBadMailFormatHeaders + } + currentHeader = append(currentHeader, line...) + } else { + // New header, save current if exists + if len(currentHeader) != 0 { + headersList.PushBack(string(bytes.TrimRight(currentHeader, "\r\n"))) + currentHeader = []byte{} + } + currentHeader = append(currentHeader, line...) + } + } + headersList.PushBack(string(currentHeader)) + return headersList, nil +} + +// getHeadersBody return headers and body +func getHeadersBody(email *[]byte) ([]byte, []byte, error) { + substitutedEmail := *email + + // only replace \n with \r\n when \r\n\r\n not exists + if bytes.Index(*email, []byte{13, 10, 13, 10}) < 0 { + // \n -> \r\n + substitutedEmail = bytes.Replace(*email, []byte{10}, []byte{13, 10}, -1) + } + + parts := bytes.SplitN(substitutedEmail, []byte{13, 10, 13, 10}, 2) + if len(parts) != 2 { + return []byte{}, []byte{}, ErrBadMailFormat + } + // Empty body + if len(parts[1]) == 0 { + parts[1] = []byte{13, 10} + } + return parts[0], parts[1], nil +} diff --git a/vendor/github.com/toorop/go-dkim/dkimHeader.go b/vendor/github.com/toorop/go-dkim/dkimHeader.go new file mode 100644 index 00000000..60248573 --- /dev/null +++ b/vendor/github.com/toorop/go-dkim/dkimHeader.go @@ -0,0 +1,545 @@ +package dkim + +import ( + "bytes" + "fmt" + "net/mail" + "net/textproto" + "strconv" + "strings" + "time" +) + +type dkimHeader struct { + // Version This tag defines the version of DKIM + // specification that applies to the signature record. + // tag v + Version string + + // The algorithm used to generate the signature.. + // Verifiers MUST support "rsa-sha1" and "rsa-sha256"; + // Signers SHOULD sign using "rsa-sha256". + // tag a + Algorithm string + + // The signature data (base64). + // Whitespace is ignored in this value and MUST be + // ignored when reassembling the original signature. + // In particular, the signing process can safely insert + // FWS in this value in arbitrary places to conform to line-length + // limits. + // tag b + SignatureData string + + // The hash of the canonicalized body part of the message as + // limited by the "l=" tag (base64; REQUIRED). + // Whitespace is ignored in this value and MUST be ignored when reassembling the original + // signature. In particular, the signing process can safely insert + // FWS in this value in arbitrary places to conform to line-length + // limits. + // tag bh + BodyHash string + + // Message canonicalization (plain-text; OPTIONAL, default is + //"simple/simple"). This tag informs the Verifier of the type of + // canonicalization used to prepare the message for signing. It + // consists of two names separated by a "slash" (%d47) character, + // corresponding to the header and body canonicalization algorithms, + // respectively. These algorithms are described in Section 3.4. If + // only one algorithm is named, that algorithm is used for the header + // and "simple" is used for the body. For example, "c=relaxed" is + // treated the same as "c=relaxed/simple". + // tag c + MessageCanonicalization string + + // The SDID claiming responsibility for an introduction of a message + // into the mail stream (plain-text; REQUIRED). Hence, the SDID + // value is used to form the query for the public key. The SDID MUST + // correspond to a valid DNS name under which the DKIM key record is + // published. The conventions and semantics used by a Signer to + // create and use a specific SDID are outside the scope of this + // specification, as is any use of those conventions and semantics. + // When presented with a signature that does not meet these + // requirements, Verifiers MUST consider the signature invalid. + // Internationalized domain names MUST be encoded as A-labels, as + // described in Section 2.3 of [RFC5890]. + // tag d + Domain string + + // Signed header fields (plain-text, but see description; REQUIRED). + // A colon-separated list of header field names that identify the + // header fields presented to the signing algorithm. The field MUST + // contain the complete list of header fields in the order presented + // to the signing algorithm. The field MAY contain names of header + // fields that do not exist when signed; nonexistent header fields do + // not contribute to the signature computation (that is, they are + // treated as the null input, including the header field name, the + // separating colon, the header field value, and any CRLF + // terminator). The field MAY contain multiple instances of a header + // field name, meaning multiple occurrences of the corresponding + // header field are included in the header hash. The field MUST NOT + // include the DKIM-Signature header field that is being created or + // verified but may include others. Folding whitespace (FWS) MAY be + // included on either side of the colon separator. Header field + // names MUST be compared against actual header field names in a + // case-insensitive manner. This list MUST NOT be empty. See + // Section 5.4 for a discussion of choosing header fields to sign and + // Section 5.4.2 for requirements when signing multiple instances of + // a single field. + // tag h + Headers []string + + // The Agent or User Identifier (AUID) on behalf of which the SDID is + // taking responsibility (dkim-quoted-printable; OPTIONAL, default is + // an empty local-part followed by an "@" followed by the domain from + // the "d=" tag). + // The syntax is a standard email address where the local-part MAY be + // omitted. The domain part of the address MUST be the same as, or a + // subdomain of, the value of the "d=" tag. + // Internationalized domain names MUST be encoded as A-labels, as + // described in Section 2.3 of [RFC5890]. + // tag i + Auid string + + // Body length count (plain-text unsigned decimal integer; OPTIONAL, + // default is entire body). This tag informs the Verifier of the + // number of octets in the body of the email after canonicalization + // included in the cryptographic hash, starting from 0 immediately + // following the CRLF preceding the body. This value MUST NOT be + // larger than the actual number of octets in the canonicalized + // message body. See further discussion in Section 8.2. + // tag l + BodyLength uint + + // A colon-separated list of query methods used to retrieve the + // public key (plain-text; OPTIONAL, default is "dns/txt"). Each + // query method is of the form "type[/options]", where the syntax and + // semantics of the options depend on the type and specified options. + // If there are multiple query mechanisms listed, the choice of query + // mechanism MUST NOT change the interpretation of the signature. + // Implementations MUST use the recognized query mechanisms in the + // order presented. Unrecognized query mechanisms MUST be ignored. + // Currently, the only valid value is "dns/txt", which defines the + // DNS TXT resource record (RR) lookup algorithm described elsewhere + // in this document. The only option defined for the "dns" query + // type is "txt", which MUST be included. Verifiers and Signers MUST + // support "dns/txt". + // tag q + QueryMethods []string + + // The selector subdividing the namespace for the "d=" (domain) tag + // (plain-text; REQUIRED). + // Internationalized selector names MUST be encoded as A-labels, as + // described in Section 2.3 of [RFC5890]. + // tag s + Selector string + + // Signature Timestamp (plain-text unsigned decimal integer; + // RECOMMENDED, default is an unknown creation time). The time that + // this signature was created. The format is the number of seconds + // since 00:00:00 on January 1, 1970 in the UTC time zone. The value + // is expressed as an unsigned integer in decimal ASCII. This value + // is not constrained to fit into a 31- or 32-bit integer. + // Implementations SHOULD be prepared to handle values up to at least + // 10^12 (until approximately AD 200,000; this fits into 40 bits). + // To avoid denial-of-service attacks, implementations MAY consider + // any value longer than 12 digits to be infinite. Leap seconds are + // not counted. Implementations MAY ignore signatures that have a + // timestamp in the future. + // tag t + SignatureTimestamp time.Time + + // Signature Expiration (plain-text unsigned decimal integer; + // RECOMMENDED, default is no expiration). The format is the same as + // in the "t=" tag, represented as an absolute date, not as a time + // delta from the signing timestamp. The value is expressed as an + // unsigned integer in decimal ASCII, with the same constraints on + // the value in the "t=" tag. Signatures MAY be considered invalid + // if the verification time at the Verifier is past the expiration + // date. The verification time should be the time that the message + // was first received at the administrative domain of the Verifier if + // that time is reliably available; otherwise, the current time + // should be used. The value of the "x=" tag MUST be greater than + // the value of the "t=" tag if both are present. + //tag x + SignatureExpiration time.Time + + // Copied header fields (dkim-quoted-printable, but see description; + // OPTIONAL, default is null). A vertical-bar-separated list of + // selected header fields present when the message was signed, + // including both the field name and value. It is not required to + // include all header fields present at the time of signing. This + // field need not contain the same header fields listed in the "h=" + // tag. The header field text itself must encode the vertical bar + // ("|", %x7C) character (i.e., vertical bars in the "z=" text are + // meta-characters, and any actual vertical bar characters in a + // copied header field must be encoded). Note that all whitespace + // must be encoded, including whitespace between the colon and the + // header field value. After encoding, FWS MAY be added at arbitrary + // locations in order to avoid excessively long lines; such + // whitespace is NOT part of the value of the header field and MUST + // be removed before decoding. + // The header fields referenced by the "h=" tag refer to the fields + // in the [RFC5322] header of the message, not to any copied fields + // in the "z=" tag. Copied header field values are for diagnostic + // use. + // tag z + CopiedHeaderFields []string + + // HeaderMailFromDomain store the raw email address of the header Mail From + // used for verifying in case of multiple DKIM header (we will prioritise + // header with d = mail from domain) + //HeaderMailFromDomain string + + // RawForsign represents the raw part (without canonicalization) of the header + // used for computint sig in verify process + RawForSign string +} + +// NewDkimHeaderBySigOptions return a new DkimHeader initioalized with sigOptions value +func newDkimHeaderBySigOptions(options SigOptions) *dkimHeader { + h := new(dkimHeader) + h.Version = "1" + h.Algorithm = options.Algo + h.MessageCanonicalization = options.Canonicalization + h.Domain = options.Domain + h.Headers = options.Headers + h.Auid = options.Auid + h.BodyLength = options.BodyLength + h.QueryMethods = options.QueryMethods + h.Selector = options.Selector + if options.AddSignatureTimestamp { + h.SignatureTimestamp = time.Now() + } + if options.SignatureExpireIn > 0 { + h.SignatureExpiration = time.Now().Add(time.Duration(options.SignatureExpireIn) * time.Second) + } + h.CopiedHeaderFields = options.CopiedHeaderFields + return h +} + +// NewFromEmail return a new DkimHeader by parsing an email +// Note: according to RFC 6376 an email can have multiple DKIM Header +// in this case we return the last inserted or the last with d== mail from +func newDkimHeaderFromEmail(email *[]byte) (*dkimHeader, error) { + m, err := mail.ReadMessage(bytes.NewReader(*email)) + if err != nil { + return nil, err + } + + // DKIM header ? + if len(m.Header[textproto.CanonicalMIMEHeaderKey("DKIM-Signature")]) == 0 { + return nil, ErrDkimHeaderNotFound + } + + // Get mail from domain + mailFromDomain := "" + mailfrom, err := mail.ParseAddress(m.Header.Get(textproto.CanonicalMIMEHeaderKey("From"))) + if err != nil { + if err.Error() != "mail: no address" { + return nil, err + } + } else { + t := strings.SplitAfter(mailfrom.Address, "@") + if len(t) > 1 { + mailFromDomain = strings.ToLower(t[1]) + } + } + + // get raw dkim header + // we can't use m.header because header key will be converted with textproto.CanonicalMIMEHeaderKey + // ie if key in header is not DKIM-Signature but Dkim-Signature or DKIM-signature ot... other + // combination of case, verify will fail. + rawHeaders, _, err := getHeadersBody(email) + if err != nil { + return nil, ErrBadMailFormat + } + rawHeadersList, err := getHeadersList(&rawHeaders) + if err != nil { + return nil, err + } + dkHeaders := []string{} + for h := rawHeadersList.Front(); h != nil; h = h.Next() { + if strings.HasPrefix(strings.ToLower(h.Value.(string)), "dkim-signature") { + dkHeaders = append(dkHeaders, h.Value.(string)) + } + } + + var keep *dkimHeader + var keepErr error + //for _, dk := range m.Header[textproto.CanonicalMIMEHeaderKey("DKIM-Signature")] { + for _, h := range dkHeaders { + parsed, err := parseDkHeader(h) + // if malformed dkim header try next + if err != nil { + keepErr = err + continue + } + // Keep first dkim headers + if keep == nil { + keep = parsed + } + // if d flag == domain keep this header and return + if mailFromDomain == parsed.Domain { + return parsed, nil + } + } + if keep == nil { + return nil, keepErr + } + return keep, nil +} + +// parseDkHeader parse raw dkim header +func parseDkHeader(header string) (dkh *dkimHeader, err error) { + dkh = new(dkimHeader) + + keyVal := strings.SplitN(header, ":", 2) + + t := strings.LastIndex(header, "b=") + if t == -1 { + return nil, ErrDkimHeaderBTagNotFound + } + dkh.RawForSign = header[0 : t+2] + p := strings.IndexByte(header[t:], ';') + if p != -1 { + dkh.RawForSign = dkh.RawForSign + header[t+p:] + } + + // Mandatory + mandatoryFlags := make(map[string]bool, 7) //(b'v', b'a', b'b', b'bh', b'd', b'h', b's') + mandatoryFlags["v"] = false + mandatoryFlags["a"] = false + mandatoryFlags["b"] = false + mandatoryFlags["bh"] = false + mandatoryFlags["d"] = false + mandatoryFlags["h"] = false + mandatoryFlags["s"] = false + + // default values + dkh.MessageCanonicalization = "simple/simple" + dkh.QueryMethods = []string{"dns/txt"} + + // unfold && clean + val := removeFWS(keyVal[1]) + val = strings.Replace(val, " ", "", -1) + + fs := strings.Split(val, ";") + for _, f := range fs { + if f == "" { + continue + } + flagData := strings.SplitN(f, "=", 2) + + // https://github.com/toorop/go-dkim/issues/2 + // if flag is not in the form key=value (eg doesn't have "=") + if len(flagData) != 2 { + return nil, ErrDkimHeaderBadFormat + } + flag := strings.ToLower(strings.TrimSpace(flagData[0])) + data := strings.TrimSpace(flagData[1]) + switch flag { + case "v": + if data != "1" { + return nil, ErrDkimVersionNotsupported + } + dkh.Version = data + mandatoryFlags["v"] = true + case "a": + dkh.Algorithm = strings.ToLower(data) + if dkh.Algorithm != "rsa-sha1" && dkh.Algorithm != "rsa-sha256" { + return nil, ErrSignBadAlgo + } + mandatoryFlags["a"] = true + case "b": + //dkh.SignatureData = removeFWS(data) + // remove all space + dkh.SignatureData = strings.Replace(removeFWS(data), " ", "", -1) + if len(dkh.SignatureData) != 0 { + mandatoryFlags["b"] = true + } + case "bh": + dkh.BodyHash = removeFWS(data) + if len(dkh.BodyHash) != 0 { + mandatoryFlags["bh"] = true + } + case "d": + dkh.Domain = strings.ToLower(data) + if len(dkh.Domain) != 0 { + mandatoryFlags["d"] = true + } + case "h": + data = strings.ToLower(data) + dkh.Headers = strings.Split(data, ":") + if len(dkh.Headers) != 0 { + mandatoryFlags["h"] = true + } + fromFound := false + for _, h := range dkh.Headers { + if h == "from" { + fromFound = true + } + } + if !fromFound { + return nil, ErrDkimHeaderNoFromInHTag + } + case "s": + dkh.Selector = strings.ToLower(data) + if len(dkh.Selector) != 0 { + mandatoryFlags["s"] = true + } + case "c": + dkh.MessageCanonicalization, err = validateCanonicalization(strings.ToLower(data)) + if err != nil { + return nil, err + } + case "i": + if data != "" { + if !strings.HasSuffix(data, dkh.Domain) { + return nil, ErrDkimHeaderDomainMismatch + } + dkh.Auid = data + } + case "l": + ui, err := strconv.ParseUint(data, 10, 32) + if err != nil { + return nil, err + } + dkh.BodyLength = uint(ui) + case "q": + dkh.QueryMethods = strings.Split(data, ":") + if len(dkh.QueryMethods) == 0 || strings.ToLower(dkh.QueryMethods[0]) != "dns/txt" { + return nil, errQueryMethodNotsupported + } + case "t": + ts, err := strconv.ParseInt(data, 10, 64) + if err != nil { + return nil, err + } + dkh.SignatureTimestamp = time.Unix(ts, 0) + + case "x": + ts, err := strconv.ParseInt(data, 10, 64) + if err != nil { + return nil, err + } + dkh.SignatureExpiration = time.Unix(ts, 0) + case "z": + dkh.CopiedHeaderFields = strings.Split(data, "|") + } + } + + // All mandatory flags are in ? + for _, p := range mandatoryFlags { + if !p { + return nil, ErrDkimHeaderMissingRequiredTag + } + } + + // default for i/Auid + if dkh.Auid == "" { + dkh.Auid = "@" + dkh.Domain + } + + // defaut for query method + if len(dkh.QueryMethods) == 0 { + dkh.QueryMethods = []string{"dns/text"} + } + + return dkh, nil + +} + +// GetHeaderBase return base header for signers +// Todo: some refactoring needed... +func (d *dkimHeader) getHeaderBaseForSigning(bodyHash string) string { + h := "DKIM-Signature: v=" + d.Version + "; a=" + d.Algorithm + "; q=" + strings.Join(d.QueryMethods, ":") + "; c=" + d.MessageCanonicalization + ";" + CRLF + TAB + subh := "s=" + d.Selector + ";" + if len(subh)+len(d.Domain)+4 > MaxHeaderLineLength { + h += subh + FWS + subh = "" + } + subh += " d=" + d.Domain + ";" + + // Auid + if len(d.Auid) != 0 { + if len(subh)+len(d.Auid)+4 > MaxHeaderLineLength { + h += subh + FWS + subh = "" + } + subh += " i=" + d.Auid + ";" + } + + /*h := "DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/simple; d=tmail.io; i=@tmail.io;" + FWS + subh := "q=dns/txt; s=test;"*/ + + // signature timestamp + if !d.SignatureTimestamp.IsZero() { + ts := d.SignatureTimestamp.Unix() + if len(subh)+14 > MaxHeaderLineLength { + h += subh + FWS + subh = "" + } + subh += " t=" + fmt.Sprintf("%d", ts) + ";" + } + if len(subh)+len(d.Domain)+4 > MaxHeaderLineLength { + h += subh + FWS + subh = "" + } + + // Expiration + if !d.SignatureExpiration.IsZero() { + ts := d.SignatureExpiration.Unix() + if len(subh)+14 > MaxHeaderLineLength { + h += subh + FWS + subh = "" + } + subh += " x=" + fmt.Sprintf("%d", ts) + ";" + } + + // body length + if d.BodyLength != 0 { + bodyLengthStr := fmt.Sprintf("%d", d.BodyLength) + if len(subh)+len(bodyLengthStr)+4 > MaxHeaderLineLength { + h += subh + FWS + subh = "" + } + subh += " l=" + bodyLengthStr + ";" + } + + // Headers + if len(subh)+len(d.Headers)+4 > MaxHeaderLineLength { + h += subh + FWS + subh = "" + } + subh += " h=" + for _, header := range d.Headers { + if len(subh)+len(header)+1 > MaxHeaderLineLength { + h += subh + FWS + subh = "" + } + subh += header + ":" + } + subh = subh[:len(subh)-1] + ";" + + // BodyHash + if len(subh)+5+len(bodyHash) > MaxHeaderLineLength { + h += subh + FWS + subh = "" + } else { + subh += " " + } + subh += "bh=" + l := len(subh) + for _, c := range bodyHash { + subh += string(c) + l++ + if l >= MaxHeaderLineLength { + h += subh + FWS + subh = "" + l = 0 + } + } + h += subh + ";" + FWS + "b=" + return h +} diff --git a/vendor/github.com/toorop/go-dkim/errors.go b/vendor/github.com/toorop/go-dkim/errors.go new file mode 100644 index 00000000..80a99da3 --- /dev/null +++ b/vendor/github.com/toorop/go-dkim/errors.go @@ -0,0 +1,94 @@ +package dkim + +import ( + "errors" +) + +var ( + // ErrSignPrivateKeyRequired when there not private key in config + ErrSignPrivateKeyRequired = errors.New("PrivateKey is required") + + // ErrSignDomainRequired when there is no domain defined in config + ErrSignDomainRequired = errors.New("Domain is required") + + // ErrSignSelectorRequired when there is no Selcteir defined in config + ErrSignSelectorRequired = errors.New("Selector is required") + + // ErrSignHeaderShouldContainsFrom If Headers is specified it should at least contain 'from' + ErrSignHeaderShouldContainsFrom = errors.New("header must contains 'from' field") + + // ErrSignBadCanonicalization If bad Canonicalization parameter + ErrSignBadCanonicalization = errors.New("bad Canonicalization parameter") + + // ErrCandNotParsePrivateKey when unable to parse private key + ErrCandNotParsePrivateKey = errors.New("can not parse private key, check format (pem) and validity") + + // ErrSignBadAlgo Bad algorithm + ErrSignBadAlgo = errors.New("bad algorithm. Only rsa-sha1 or rsa-sha256 are permitted") + + // ErrBadMailFormat unable to parse mail + ErrBadMailFormat = errors.New("bad mail format") + + // ErrBadMailFormatHeaders bad headers format (not DKIM Header) + ErrBadMailFormatHeaders = errors.New("bad mail format found in headers") + + // ErrBadDKimTagLBodyTooShort bad l tag + ErrBadDKimTagLBodyTooShort = errors.New("bad tag l or bodyLength option. Body length < l value") + + // ErrDkimHeaderBadFormat when errors found in DKIM header + ErrDkimHeaderBadFormat = errors.New("bad DKIM header format") + + // ErrDkimHeaderNotFound when there's no DKIM-Signature header in an email we have to verify + ErrDkimHeaderNotFound = errors.New("no DKIM-Signature header field found ") + + // ErrDkimHeaderBTagNotFound when there's no b tag + ErrDkimHeaderBTagNotFound = errors.New("no tag 'b' found in dkim header") + + // ErrDkimHeaderNoFromInHTag when from is missing in h tag + ErrDkimHeaderNoFromInHTag = errors.New("'from' header is missing in h tag") + + // ErrDkimHeaderMissingRequiredTag when a required tag is missing + ErrDkimHeaderMissingRequiredTag = errors.New("signature missing required tag") + + // ErrDkimHeaderDomainMismatch if i tag is not a sub domain of d tag + ErrDkimHeaderDomainMismatch = errors.New("domain mismatch") + + // ErrDkimVersionNotsupported version not supported + ErrDkimVersionNotsupported = errors.New("incompatible version") + + // Query method unsupported + errQueryMethodNotsupported = errors.New("query method not supported") + + // ErrVerifyBodyHash when body hash doesn't verify + ErrVerifyBodyHash = errors.New("body hash did not verify") + + // ErrVerifyNoKeyForSignature no key + ErrVerifyNoKeyForSignature = errors.New("no key for verify") + + // ErrVerifyKeyUnavailable when service (dns) is anavailable + ErrVerifyKeyUnavailable = errors.New("key unavailable") + + // ErrVerifyTagVMustBeTheFirst if present the v tag must be the firts in the record + ErrVerifyTagVMustBeTheFirst = errors.New("pub key syntax error: v tag must be the first") + + // ErrVerifyVersionMusBeDkim1 if présent flag v (version) must be DKIM1 + ErrVerifyVersionMusBeDkim1 = errors.New("flag v must be set to DKIM1") + + // ErrVerifyBadKeyType bad type for pub key (only rsa is accepted) + ErrVerifyBadKeyType = errors.New("bad type for key type") + + // ErrVerifyRevokedKey key(s) for this selector is revoked (p is empty) + ErrVerifyRevokedKey = errors.New("revoked key") + + // ErrVerifyBadKey when we can't parse pubkey + ErrVerifyBadKey = errors.New("unable to parse pub key") + + // ErrVerifyNoKey when no key is found on DNS record + ErrVerifyNoKey = errors.New("no public key found in DNS TXT") + + // ErrVerifySignatureHasExpired when signature has expired + ErrVerifySignatureHasExpired = errors.New("signature has expired") + + // ErrVerifyInappropriateHashAlgo when h tag in pub key doesn't contain hash algo from a tag of DKIM header + ErrVerifyInappropriateHashAlgo = errors.New("inappropriate has algorithm") +) diff --git a/vendor/github.com/toorop/go-dkim/pubKeyRep.go b/vendor/github.com/toorop/go-dkim/pubKeyRep.go new file mode 100644 index 00000000..7a3ecf6f --- /dev/null +++ b/vendor/github.com/toorop/go-dkim/pubKeyRep.go @@ -0,0 +1,181 @@ +package dkim + +import ( + "crypto/rsa" + "crypto/x509" + "encoding/base64" + "io/ioutil" + "mime/quotedprintable" + "net" + "strings" +) + +// PubKeyRep represents a parsed version of public key record +type PubKeyRep struct { + Version string + HashAlgo []string + KeyType string + Note string + PubKey rsa.PublicKey + ServiceType []string + FlagTesting bool // flag y + FlagIMustBeD bool // flag i +} + +// DNSOptions holds settings for looking up DNS records +type DNSOptions struct { + netLookupTXT func(name string) ([]string, error) +} + +// DNSOpt represents an optional setting for looking up DNS records +type DNSOpt interface { + apply(*DNSOptions) +} + +type dnsOpt func(*DNSOptions) + +func (opt dnsOpt) apply(dnsOpts *DNSOptions) { + opt(dnsOpts) +} + +// DNSOptLookupTXT sets the function to use to lookup TXT records. +// +// This should probably only be used in tests. +func DNSOptLookupTXT(netLookupTXT func(name string) ([]string, error)) DNSOpt { + return dnsOpt(func(opts *DNSOptions) { + opts.netLookupTXT = netLookupTXT + }) +} + +// NewPubKeyRespFromDNS retrieves the TXT record from DNS based on the specified domain and selector +// and parses it. +func NewPubKeyRespFromDNS(selector, domain string, opts ...DNSOpt) (*PubKeyRep, verifyOutput, error) { + dnsOpts := DNSOptions{} + + for _, opt := range opts { + opt.apply(&dnsOpts) + } + + if dnsOpts.netLookupTXT == nil { + dnsOpts.netLookupTXT = net.LookupTXT + } + + txt, err := dnsOpts.netLookupTXT(selector + "._domainkey." + domain) + if err != nil { + if strings.HasSuffix(err.Error(), "no such host") { + return nil, PERMFAIL, ErrVerifyNoKeyForSignature + } + + return nil, TEMPFAIL, ErrVerifyKeyUnavailable + } + + // empty record + if len(txt) == 0 { + return nil, PERMFAIL, ErrVerifyNoKeyForSignature + } + + // parsing, we keep the first record + // TODO: if there is multiple record + + return NewPubKeyResp(txt[0]) +} + +// NewPubKeyResp parses DKIM record (usually from DNS) +func NewPubKeyResp(dkimRecord string) (*PubKeyRep, verifyOutput, error) { + pkr := new(PubKeyRep) + pkr.Version = "DKIM1" + pkr.HashAlgo = []string{"sha1", "sha256"} + pkr.KeyType = "rsa" + pkr.FlagTesting = false + pkr.FlagIMustBeD = false + + p := strings.Split(dkimRecord, ";") + for i, data := range p { + keyVal := strings.SplitN(data, "=", 2) + val := "" + if len(keyVal) > 1 { + val = strings.TrimSpace(keyVal[1]) + } + switch strings.ToLower(strings.TrimSpace(keyVal[0])) { + case "v": + // RFC: is this tag is specified it MUST be the first in the record + if i != 0 { + return nil, PERMFAIL, ErrVerifyTagVMustBeTheFirst + } + pkr.Version = val + if pkr.Version != "DKIM1" { + return nil, PERMFAIL, ErrVerifyVersionMusBeDkim1 + } + case "h": + p := strings.Split(strings.ToLower(val), ":") + pkr.HashAlgo = []string{} + for _, h := range p { + h = strings.TrimSpace(h) + if h == "sha1" || h == "sha256" { + pkr.HashAlgo = append(pkr.HashAlgo, h) + } + } + // if empty switch back to default + if len(pkr.HashAlgo) == 0 { + pkr.HashAlgo = []string{"sha1", "sha256"} + } + case "k": + if strings.ToLower(val) != "rsa" { + return nil, PERMFAIL, ErrVerifyBadKeyType + } + case "n": + qp, err := ioutil.ReadAll(quotedprintable.NewReader(strings.NewReader(val))) + if err == nil { + val = string(qp) + } + pkr.Note = val + case "p": + rawkey := val + if rawkey == "" { + return nil, PERMFAIL, ErrVerifyRevokedKey + } + un64, err := base64.StdEncoding.DecodeString(rawkey) + if err != nil { + return nil, PERMFAIL, ErrVerifyBadKey + } + pk, err := x509.ParsePKIXPublicKey(un64) + if pk, ok := pk.(*rsa.PublicKey); ok { + pkr.PubKey = *pk + } + case "s": + t := strings.Split(strings.ToLower(val), ":") + for _, tt := range t { + tt = strings.TrimSpace(tt) + switch tt { + case "*": + pkr.ServiceType = append(pkr.ServiceType, "all") + case "email": + pkr.ServiceType = append(pkr.ServiceType, tt) + } + } + case "t": + flags := strings.Split(strings.ToLower(val), ":") + for _, flag := range flags { + flag = strings.TrimSpace(flag) + switch flag { + case "y": + pkr.FlagTesting = true + case "s": + pkr.FlagIMustBeD = true + } + } + } + } + + // if no pubkey + if pkr.PubKey == (rsa.PublicKey{}) { + return nil, PERMFAIL, ErrVerifyNoKey + } + + // No service type + if len(pkr.ServiceType) == 0 { + pkr.ServiceType = []string{"all"} + } + + return pkr, SUCCESS, nil +} diff --git a/vendor/github.com/toorop/go-dkim/watch b/vendor/github.com/toorop/go-dkim/watch new file mode 100644 index 00000000..82b58445 --- /dev/null +++ b/vendor/github.com/toorop/go-dkim/watch @@ -0,0 +1,4 @@ +while true +do +inotifywait -q -r -e modify,attrib,close_write,move,create,delete . && echo "--------------" && go test -v +done \ No newline at end of file diff --git a/vendor/modules.txt b/vendor/modules.txt index 4109f6a4..08718808 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -59,6 +59,9 @@ github.com/tidwall/rtree github.com/tidwall/rtree/base # github.com/tidwall/tinyqueue v0.0.0-20180302190814-1e39f5511563 github.com/tidwall/tinyqueue +# github.com/toorop/go-dkim v0.0.0-20191019073156-897ad64a2eeb +## explicit +github.com/toorop/go-dkim # golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 ## explicit golang.org/x/crypto/bcrypt