mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
support cram-md5 authentication for imap and smtp
and change thunderbird autoconfiguration to use it. unfortunately, for microsoft autodiscover, there appears to be no way to request secure password negotiation. so it will default to plain text auth. cram-md5 is less secure than scram-sha-*, but thunderbird does not yet support scram auth. it currently chooses "plain", sending the literal password over the connection (which is TLS-protected, but we don't want to receive clear text passwords). in short, cram-md5 is better than nothing... for cram-md5 to work, a new set of derived credentials need to be stored in the database. so you need to save your password again to make it work. this was also the case with the scram-sha-1 addition, but i forgot to mention it then.
This commit is contained in:
parent
f83fe79f96
commit
e52c9d36a6
9 changed files with 306 additions and 22 deletions
|
@ -103,14 +103,14 @@ func autoconfHandle(l config.Listener) http.HandlerFunc {
|
||||||
log.Error("autoconfig: no imap configured?")
|
log.Error("autoconfig: no imap configured?")
|
||||||
}
|
}
|
||||||
|
|
||||||
// todo: specify SCRAM-SHA256 once thunderbird and autoconfig supports it. we could implement CRAM-MD5 and use it.
|
// todo: specify SCRAM-SHA-256 once thunderbird and autoconfig supports it. or perhaps that will fall under "password-encrypted" by then.
|
||||||
|
|
||||||
resp.EmailProvider.IncomingServer.Type = "imap"
|
resp.EmailProvider.IncomingServer.Type = "imap"
|
||||||
resp.EmailProvider.IncomingServer.Hostname = hostname.ASCII
|
resp.EmailProvider.IncomingServer.Hostname = hostname.ASCII
|
||||||
resp.EmailProvider.IncomingServer.Port = imapPort
|
resp.EmailProvider.IncomingServer.Port = imapPort
|
||||||
resp.EmailProvider.IncomingServer.SocketType = imapSocket
|
resp.EmailProvider.IncomingServer.SocketType = imapSocket
|
||||||
resp.EmailProvider.IncomingServer.Username = email
|
resp.EmailProvider.IncomingServer.Username = email
|
||||||
resp.EmailProvider.IncomingServer.Authentication = "password-cleartext"
|
resp.EmailProvider.IncomingServer.Authentication = "password-encrypted"
|
||||||
|
|
||||||
var smtpPort int
|
var smtpPort int
|
||||||
var smtpSocket string
|
var smtpSocket string
|
||||||
|
@ -133,7 +133,7 @@ func autoconfHandle(l config.Listener) http.HandlerFunc {
|
||||||
resp.EmailProvider.OutgoingServer.Port = smtpPort
|
resp.EmailProvider.OutgoingServer.Port = smtpPort
|
||||||
resp.EmailProvider.OutgoingServer.SocketType = smtpSocket
|
resp.EmailProvider.OutgoingServer.SocketType = smtpSocket
|
||||||
resp.EmailProvider.OutgoingServer.Username = email
|
resp.EmailProvider.OutgoingServer.Username = email
|
||||||
resp.EmailProvider.OutgoingServer.Authentication = "password-cleartext"
|
resp.EmailProvider.OutgoingServer.Authentication = "password-encrypted"
|
||||||
|
|
||||||
// todo: should we put the email address in the URL?
|
// todo: should we put the email address in the URL?
|
||||||
resp.ClientConfigUpdate.URL = fmt.Sprintf("https://%s/mail/config-v1.1.xml", hostname.ASCII)
|
resp.ClientConfigUpdate.URL = fmt.Sprintf("https://%s/mail/config-v1.1.xml", hostname.ASCII)
|
||||||
|
@ -150,10 +150,14 @@ func autoconfHandle(l config.Listener) http.HandlerFunc {
|
||||||
|
|
||||||
// Autodiscover from Microsoft, also used by Thunderbird.
|
// Autodiscover from Microsoft, also used by Thunderbird.
|
||||||
// User should create a DNS record: _autodiscover._tcp.<domain> IN SRV 0 0 443 <hostname or autodiscover.<domain>>
|
// User should create a DNS record: _autodiscover._tcp.<domain> IN SRV 0 0 443 <hostname or autodiscover.<domain>>
|
||||||
// In practice, autodiscover does not seem to work (any more). A connectivity test
|
//
|
||||||
// tool for outlook is available on https://testconnectivity.microsoft.com/, it has
|
// In practice, autodiscover does not seem to work wit microsoft clients. A
|
||||||
// an option to do "Autodiscover to detect server settings". Incoming TLS
|
// connectivity test tool for outlook is available on
|
||||||
// connections are all failing, with various errors.
|
// https://testconnectivity.microsoft.com/, it has an option to do "Autodiscover to
|
||||||
|
// detect server settings". Incoming TLS connections are all failing, with various
|
||||||
|
// errors.
|
||||||
|
//
|
||||||
|
// Thunderbird does understand autodiscover.
|
||||||
func autodiscoverHandle(l config.Listener) http.HandlerFunc {
|
func autodiscoverHandle(l config.Listener) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
log := xlog.WithContext(r.Context())
|
log := xlog.WithContext(r.Context())
|
||||||
|
@ -197,7 +201,10 @@ func autodiscoverHandle(l config.Listener) http.HandlerFunc {
|
||||||
// High-level starting point, https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/78530279-d042-4eb0-a1f4-03b18143cd19
|
// High-level starting point, https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/78530279-d042-4eb0-a1f4-03b18143cd19
|
||||||
// Request: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/2096fab2-9c3c-40b9-b123-edf6e8d55a9b
|
// Request: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/2096fab2-9c3c-40b9-b123-edf6e8d55a9b
|
||||||
// Response, protocol: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/f4238db6-a983-435c-807a-b4b4a624c65b
|
// Response, protocol: https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/f4238db6-a983-435c-807a-b4b4a624c65b
|
||||||
// It appears autodiscover does not allow specifying SCRAM-SHA256 as authentication method. See https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/21fd2dd5-c4ee-485b-94fb-e7db5da93726
|
// It appears autodiscover does not allow specifying SCRAM-SHA-256 as
|
||||||
|
// authentication method, or any authentication method that real clients actually
|
||||||
|
// use. See
|
||||||
|
// https://learn.microsoft.com/en-us/openspecs/exchange_server_protocols/ms-oxdscli/21fd2dd5-c4ee-485b-94fb-e7db5da93726
|
||||||
|
|
||||||
var imapPort int
|
var imapPort int
|
||||||
imapSSL := "off"
|
imapSSL := "off"
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
package imapserver
|
package imapserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/md5"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"hash"
|
"hash"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
@ -124,3 +127,50 @@ func testAuthenticateSCRAM(t *testing.T, method string, h func() hash.Hash) {
|
||||||
tc.transactf("bad", "authenticate %s %s", method, base64.StdEncoding.EncodeToString([]byte("bad data")))
|
tc.transactf("bad", "authenticate %s %s", method, base64.StdEncoding.EncodeToString([]byte("bad data")))
|
||||||
tc.close()
|
tc.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAuthenticateCRAMMD5(t *testing.T) {
|
||||||
|
tc := start(t)
|
||||||
|
|
||||||
|
tc.transactf("no", "authenticate bogus ")
|
||||||
|
tc.transactf("bad", "authenticate CRAM-MD5 not base64...")
|
||||||
|
tc.transactf("bad", "authenticate CRAM-MD5 %s", base64.StdEncoding.EncodeToString([]byte("baddata")))
|
||||||
|
tc.transactf("bad", "authenticate CRAM-MD5 %s", base64.StdEncoding.EncodeToString([]byte("bad data")))
|
||||||
|
|
||||||
|
auth := func(status string, username, password string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
tc.client.LastTag = "x001"
|
||||||
|
tc.writelinef("%s authenticate CRAM-MD5", tc.client.LastTag)
|
||||||
|
|
||||||
|
xreadContinuation := func() []byte {
|
||||||
|
line, _, result, rerr := tc.client.ReadContinuation()
|
||||||
|
tc.check(rerr, "read continuation")
|
||||||
|
if result.Status != "" {
|
||||||
|
tc.t.Fatalf("expected continuation")
|
||||||
|
}
|
||||||
|
buf, err := base64.StdEncoding.DecodeString(line)
|
||||||
|
tc.check(err, "parsing base64 from remote")
|
||||||
|
return buf
|
||||||
|
}
|
||||||
|
|
||||||
|
chal := xreadContinuation()
|
||||||
|
h := hmac.New(md5.New, []byte(password))
|
||||||
|
h.Write([]byte(chal))
|
||||||
|
resp := fmt.Sprintf("%s %x", username, h.Sum(nil))
|
||||||
|
tc.writelinef("%s", base64.StdEncoding.EncodeToString([]byte(resp)))
|
||||||
|
|
||||||
|
_, result, err := tc.client.Response()
|
||||||
|
tc.check(err, "read response")
|
||||||
|
if string(result.Status) != strings.ToUpper(status) {
|
||||||
|
tc.t.Fatalf("got status %q, expected %q", result.Status, strings.ToUpper(status))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auth("no", "mjl@mox.example", "badpass")
|
||||||
|
auth("no", "mjl@mox.example", "")
|
||||||
|
auth("no", "other@mox.example", "testtest")
|
||||||
|
|
||||||
|
auth("ok", "mjl@mox.example", "testtest")
|
||||||
|
|
||||||
|
tc.close()
|
||||||
|
}
|
||||||
|
|
|
@ -39,6 +39,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
@ -118,8 +119,9 @@ var (
|
||||||
// ID: ../rfc/2971
|
// ID: ../rfc/2971
|
||||||
// AUTH=SCRAM-SHA-256: ../rfc/7677 ../rfc/5802
|
// AUTH=SCRAM-SHA-256: ../rfc/7677 ../rfc/5802
|
||||||
// AUTH=SCRAM-SHA-1: ../rfc/5802
|
// AUTH=SCRAM-SHA-1: ../rfc/5802
|
||||||
|
// AUTH=CRAM-MD5: ../rfc/2195
|
||||||
// APPENDLIMIT, we support the max possible size, 1<<63 - 1: ../rfc/7889:129
|
// APPENDLIMIT, we support the max possible size, 1<<63 - 1: ../rfc/7889:129
|
||||||
const serverCapabilities = "IMAP4rev2 IMAP4rev1 ENABLE LITERAL+ IDLE SASL-IR BINARY UNSELECT UIDPLUS ESEARCH SEARCHRES MOVE UTF8=ONLY LIST-EXTENDED SPECIAL-USE LIST-STATUS AUTH=SCRAM-SHA-256 AUTH=SCRAM-SHA-1 ID APPENDLIMIT=9223372036854775807"
|
const serverCapabilities = "IMAP4rev2 IMAP4rev1 ENABLE LITERAL+ IDLE SASL-IR BINARY UNSELECT UIDPLUS ESEARCH SEARCHRES MOVE UTF8=ONLY LIST-EXTENDED SPECIAL-USE LIST-STATUS AUTH=SCRAM-SHA-256 AUTH=SCRAM-SHA-1 AUTH=CRAM-MD5 ID APPENDLIMIT=9223372036854775807"
|
||||||
|
|
||||||
type conn struct {
|
type conn struct {
|
||||||
cid int64
|
cid int64
|
||||||
|
@ -1392,6 +1394,71 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
|
||||||
c.username = authc
|
c.username = authc
|
||||||
authResult = "ok"
|
authResult = "ok"
|
||||||
|
|
||||||
|
case "CRAM-MD5":
|
||||||
|
authVariant = strings.ToLower(authType)
|
||||||
|
|
||||||
|
// ../rfc/9051:1462
|
||||||
|
p.xempty()
|
||||||
|
|
||||||
|
// ../rfc/2195:82
|
||||||
|
chal := fmt.Sprintf("<%d.%d@%s>", uint64(mox.CryptoRandInt()), time.Now().UnixNano(), mox.Conf.Static.HostnameDomain.ASCII)
|
||||||
|
c.writelinef("+ %s", base64.StdEncoding.EncodeToString([]byte(chal)))
|
||||||
|
|
||||||
|
resp := xreadContinuation()
|
||||||
|
t := strings.Split(string(resp), " ")
|
||||||
|
if len(t) != 2 || len(t[1]) != 2*md5.Size {
|
||||||
|
xsyntaxErrorf("malformed cram-md5 response")
|
||||||
|
}
|
||||||
|
addr := t[0]
|
||||||
|
c.log.Info("cram-md5 auth", mlog.Field("address", addr))
|
||||||
|
acc, _, err := store.OpenEmail(addr)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrUnknownCredentials) {
|
||||||
|
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
|
||||||
|
}
|
||||||
|
xserverErrorf("looking up address: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if acc != nil {
|
||||||
|
err := acc.Close()
|
||||||
|
c.xsanity(err, "close account")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var ipadhash, opadhash hash.Hash
|
||||||
|
acc.WithRLock(func() {
|
||||||
|
err := acc.DB.Read(func(tx *bstore.Tx) error {
|
||||||
|
password, err := bstore.QueryTx[store.Password](tx).Get()
|
||||||
|
if err == bstore.ErrAbsent {
|
||||||
|
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ipadhash = password.CRAMMD5.Ipad
|
||||||
|
opadhash = password.CRAMMD5.Opad
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
xcheckf(err, "tx read")
|
||||||
|
})
|
||||||
|
if ipadhash == nil || opadhash == nil {
|
||||||
|
c.log.Info("cram-md5 auth attempt without derived secrets set, save password again to store secrets", mlog.Field("address", addr))
|
||||||
|
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ../rfc/2195:138 ../rfc/2104:142
|
||||||
|
ipadhash.Write([]byte(chal))
|
||||||
|
opadhash.Write(ipadhash.Sum(nil))
|
||||||
|
digest := fmt.Sprintf("%x", opadhash.Sum(nil))
|
||||||
|
if digest != t[1] {
|
||||||
|
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.account = acc
|
||||||
|
acc = nil // Cancel cleanup.
|
||||||
|
c.username = addr
|
||||||
|
authResult = "ok"
|
||||||
|
|
||||||
case "SCRAM-SHA-1", "SCRAM-SHA-256":
|
case "SCRAM-SHA-1", "SCRAM-SHA-256":
|
||||||
// todo: improve handling of errors during scram. e.g. invalid parameters. should we abort the imap command, or continue until the end and respond with a scram-level error?
|
// todo: improve handling of errors during scram. e.g. invalid parameters. should we abort the imap command, or continue until the end and respond with a scram-level error?
|
||||||
// todo: use single implementation between ../imapserver/server.go and ../smtpserver/server.go
|
// todo: use single implementation between ../imapserver/server.go and ../smtpserver/server.go
|
||||||
|
@ -1438,6 +1505,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
|
||||||
xscram = password.SCRAMSHA256
|
xscram = password.SCRAMSHA256
|
||||||
}
|
}
|
||||||
if err == bstore.ErrAbsent || err == nil && (len(xscram.Salt) == 0 || xscram.Iterations == 0 || len(xscram.SaltedPassword) == 0) {
|
if err == bstore.ErrAbsent || err == nil && (len(xscram.Salt) == 0 || xscram.Iterations == 0 || len(xscram.SaltedPassword) == 0) {
|
||||||
|
c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", mlog.Field("address", ss.Authentication))
|
||||||
xuserErrorf("scram not possible")
|
xuserErrorf("scram not possible")
|
||||||
}
|
}
|
||||||
xcheckf(err, "fetching credentials")
|
xcheckf(err, "fetching credentials")
|
||||||
|
|
8
main.go
8
main.go
|
@ -876,8 +876,10 @@ func cmdSetaccountpassword(c *cmd) {
|
||||||
c.params = "address"
|
c.params = "address"
|
||||||
c.help = `Set new password an account.
|
c.help = `Set new password an account.
|
||||||
|
|
||||||
The password is read from stdin. Its bcrypt hash and SCRAM-SHA-256 derivations
|
The password is read from stdin. Secrets derived from the password, but not the
|
||||||
are stored in the accounts database.
|
password itself, are stored in the account database. The stored secrets are for
|
||||||
|
authentication with: scram-sha-256, scram-sha-1, cram-md5, plain text (bcrypt
|
||||||
|
hash).
|
||||||
|
|
||||||
Any email address configured for the account can be used.
|
Any email address configured for the account can be used.
|
||||||
`
|
`
|
||||||
|
@ -1958,7 +1960,7 @@ binary should be setgid that group:
|
||||||
if !submitconf.STARTTLS {
|
if !submitconf.STARTTLS {
|
||||||
tlsMode = smtpclient.TLSSkip
|
tlsMode = smtpclient.TLSSkip
|
||||||
}
|
}
|
||||||
// todo: should have more auth options, scram-sha-256 at least.
|
// todo: should have more auth options, scram-sha-256 at least, perhaps cram-md5 for compatibility as well.
|
||||||
authLine := fmt.Sprintf("AUTH PLAIN %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("\u0000%s\u0000%s", submitconf.Username, submitconf.Password))))
|
authLine := fmt.Sprintf("AUTH PLAIN %s", base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("\u0000%s\u0000%s", submitconf.Username, submitconf.Password))))
|
||||||
mox.Conf.Static.HostnameDomain.ASCII = submitconf.LocalHostname
|
mox.Conf.Static.HostnameDomain.ASCII = submitconf.LocalHostname
|
||||||
client, err := smtpclient.New(ctx, mlog.New("sendmail"), conn, tlsMode, submitconf.Host, authLine)
|
client, err := smtpclient.New(ctx, mlog.New("sendmail"), conn, tlsMode, submitconf.Host, authLine)
|
||||||
|
|
|
@ -13,7 +13,7 @@ var (
|
||||||
},
|
},
|
||||||
[]string{
|
[]string{
|
||||||
"kind", // submission, imap, httpaccount, httpadmin
|
"kind", // submission, imap, httpaccount, httpadmin
|
||||||
"variant", // login, plain, scram-sha-256, httpbasic
|
"variant", // login, plain, scram-sha-256, scram-sha-1, cram-md5, httpbasic
|
||||||
// todo: we currently only use badcreds, but known baduser can be helpful
|
// todo: we currently only use badcreds, but known baduser can be helpful
|
||||||
"result", // ok, baduser, badpassword, badcreds, error, aborted
|
"result", // ok, baduser, badpassword, badcreds, error, aborted
|
||||||
},
|
},
|
||||||
|
|
|
@ -9,10 +9,11 @@ import (
|
||||||
|
|
||||||
// NewRand returns a new PRNG seeded with random bytes from crypto/rand.
|
// NewRand returns a new PRNG seeded with random bytes from crypto/rand.
|
||||||
func NewRand() *mathrand.Rand {
|
func NewRand() *mathrand.Rand {
|
||||||
return mathrand.New(mathrand.NewSource(cryptoRandInt()))
|
return mathrand.New(mathrand.NewSource(CryptoRandInt()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func cryptoRandInt() int64 {
|
// CryptoRandInt returns a cryptographically random number.
|
||||||
|
func CryptoRandInt() int64 {
|
||||||
buf := make([]byte, 8)
|
buf := make([]byte, 8)
|
||||||
_, err := cryptorand.Read(buf)
|
_, err := cryptorand.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -226,6 +226,8 @@ and many more, see http://sieve.info/documents
|
||||||
|
|
||||||
# SASL
|
# SASL
|
||||||
|
|
||||||
|
2104 HMAC: Keyed-Hashing for Message Authentication
|
||||||
|
2195 IMAP/POP AUTHorize Extension for Simple Challenge/Response
|
||||||
4013 (obsoleted by RFC 7613) SASLprep: Stringprep Profile for User Names and Passwords
|
4013 (obsoleted by RFC 7613) SASLprep: Stringprep Profile for User Names and Passwords
|
||||||
4422 Simple Authentication and Security Layer (SASL)
|
4422 Simple Authentication and Security Layer (SASL)
|
||||||
4505 Anonymous Simple Authentication and Security Layer (SASL) Mechanism
|
4505 Anonymous Simple Authentication and Security Layer (SASL) Mechanism
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
@ -682,7 +683,7 @@ func (c *conn) cmdHello(p *parser, ehlo bool) {
|
||||||
if c.submission {
|
if c.submission {
|
||||||
// ../rfc/4954:123
|
// ../rfc/4954:123
|
||||||
if c.tls || !c.requireTLSForAuth {
|
if c.tls || !c.requireTLSForAuth {
|
||||||
c.bwritelinef("250-AUTH PLAIN SCRAM-SHA-256 SCRAM-SHA-1")
|
c.bwritelinef("250-AUTH PLAIN SCRAM-SHA-256 SCRAM-SHA-1 CRAM-MD5")
|
||||||
} else {
|
} else {
|
||||||
c.bwritelinef("250-AUTH ")
|
c.bwritelinef("250-AUTH ")
|
||||||
}
|
}
|
||||||
|
@ -864,6 +865,75 @@ func (c *conn) cmdAuth(p *parser) {
|
||||||
// ../rfc/4954:276
|
// ../rfc/4954:276
|
||||||
c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
|
c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
|
||||||
|
|
||||||
|
case "CRAM-MD5":
|
||||||
|
authVariant = strings.ToLower(mech)
|
||||||
|
|
||||||
|
p.xempty()
|
||||||
|
|
||||||
|
// ../rfc/2195:82
|
||||||
|
chal := fmt.Sprintf("<%d.%d@%s>", uint64(mox.CryptoRandInt()), time.Now().UnixNano(), mox.Conf.Static.HostnameDomain.ASCII)
|
||||||
|
c.writelinef("%d %s", smtp.C334ContinueAuth, base64.StdEncoding.EncodeToString([]byte(chal)))
|
||||||
|
|
||||||
|
resp := xreadContinuation()
|
||||||
|
t := strings.Split(string(resp), " ")
|
||||||
|
if len(t) != 2 || len(t[1]) != 2*md5.Size {
|
||||||
|
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "malformed cram-md5 response")
|
||||||
|
}
|
||||||
|
addr := t[0]
|
||||||
|
c.log.Info("cram-md5 auth", mlog.Field("address", addr))
|
||||||
|
acc, _, err := store.OpenEmail(addr)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrUnknownCredentials) {
|
||||||
|
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
xcheckf(err, "looking up address")
|
||||||
|
defer func() {
|
||||||
|
if acc != nil {
|
||||||
|
err := acc.Close()
|
||||||
|
if err != nil {
|
||||||
|
c.log.Errorx("closing account", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
var ipadhash, opadhash hash.Hash
|
||||||
|
acc.WithRLock(func() {
|
||||||
|
err := acc.DB.Read(func(tx *bstore.Tx) error {
|
||||||
|
password, err := bstore.QueryTx[store.Password](tx).Get()
|
||||||
|
if err == bstore.ErrAbsent {
|
||||||
|
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ipadhash = password.CRAMMD5.Ipad
|
||||||
|
opadhash = password.CRAMMD5.Opad
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
xcheckf(err, "tx read")
|
||||||
|
})
|
||||||
|
if ipadhash == nil || opadhash == nil {
|
||||||
|
c.log.Info("cram-md5 auth attempt without derived secrets set, save password again to store secrets", mlog.Field("address", addr))
|
||||||
|
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ../rfc/2195:138 ../rfc/2104:142
|
||||||
|
ipadhash.Write([]byte(chal))
|
||||||
|
opadhash.Write(ipadhash.Sum(nil))
|
||||||
|
digest := fmt.Sprintf("%x", opadhash.Sum(nil))
|
||||||
|
if digest != t[1] {
|
||||||
|
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad user/pass")
|
||||||
|
}
|
||||||
|
|
||||||
|
authResult = "ok"
|
||||||
|
c.authFailed = 0
|
||||||
|
c.account = acc
|
||||||
|
acc = nil // Cancel cleanup.
|
||||||
|
c.username = addr
|
||||||
|
// ../rfc/4954:276
|
||||||
|
c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
|
||||||
|
|
||||||
case "SCRAM-SHA-1", "SCRAM-SHA-256":
|
case "SCRAM-SHA-1", "SCRAM-SHA-256":
|
||||||
// todo: improve handling of errors during scram. e.g. invalid parameters. should we abort the imap command, or continue until the end and respond with a scram-level error?
|
// todo: improve handling of errors during scram. e.g. invalid parameters. should we abort the imap command, or continue until the end and respond with a scram-level error?
|
||||||
// todo: use single implementation between ../imapserver/server.go and ../smtpserver/server.go
|
// todo: use single implementation between ../imapserver/server.go and ../smtpserver/server.go
|
||||||
|
@ -910,6 +980,7 @@ func (c *conn) cmdAuth(p *parser) {
|
||||||
xscram = password.SCRAMSHA256
|
xscram = password.SCRAMSHA256
|
||||||
}
|
}
|
||||||
if err == bstore.ErrAbsent || err == nil && (len(xscram.Salt) == 0 || xscram.Iterations == 0 || len(xscram.SaltedPassword) == 0) {
|
if err == bstore.ErrAbsent || err == nil && (len(xscram.Salt) == 0 || xscram.Iterations == 0 || len(xscram.SaltedPassword) == 0) {
|
||||||
|
c.log.Info("scram auth attempt without derived secrets set, save password again to store secrets", mlog.Field("address", ss.Authentication))
|
||||||
xsmtpUserErrorf(smtp.C454TempAuthFail, smtp.SeSys3Other0, "scram not possible")
|
xsmtpUserErrorf(smtp.C454TempAuthFail, smtp.SeSys3Other0, "scram not possible")
|
||||||
}
|
}
|
||||||
xcheckf(err, "fetching credentials")
|
xcheckf(err, "fetching credentials")
|
||||||
|
@ -947,8 +1018,6 @@ func (c *conn) cmdAuth(p *parser) {
|
||||||
c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
|
c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// todo future: implement scram-sha-256 ../rfc/7677
|
|
||||||
// todo future: possibly implement cram-md5, at least where we allow PLAIN. ../rfc/4954:348
|
|
||||||
// ../rfc/4954:176
|
// ../rfc/4954:176
|
||||||
xsmtpUserErrorf(smtp.C504ParamNotImpl, smtp.SeProto5BadParams4, "mechanism %s not supported", mech)
|
xsmtpUserErrorf(smtp.C504ParamNotImpl, smtp.SeProto5BadParams4, "mechanism %s not supported", mech)
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,11 +24,14 @@ package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"encoding"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
@ -70,11 +73,68 @@ type SCRAM struct {
|
||||||
SaltedPassword []byte
|
SaltedPassword []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// Password holds a bcrypt hash for logging in with SMTP/IMAP/admin.
|
// CRAMMD5 holds HMAC ipad and opad hashes that are initialized with the first
|
||||||
|
// block with (a derivation of) the key/password, so we don't store the password in plain
|
||||||
|
// text.
|
||||||
|
type CRAMMD5 struct {
|
||||||
|
Ipad hash.Hash
|
||||||
|
Opad hash.Hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinaryMarshal is used by bstore to store the ipad/opad hash states.
|
||||||
|
func (c CRAMMD5) MarshalBinary() ([]byte, error) {
|
||||||
|
if c.Ipad == nil || c.Opad == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ipad, err := c.Ipad.(encoding.BinaryMarshaler).MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal ipad: %v", err)
|
||||||
|
}
|
||||||
|
opad, err := c.Opad.(encoding.BinaryMarshaler).MarshalBinary()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal opad: %v", err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 2+len(ipad)+len(opad))
|
||||||
|
ipadlen := uint16(len(ipad))
|
||||||
|
buf[0] = byte(ipadlen >> 8)
|
||||||
|
buf[1] = byte(ipadlen >> 0)
|
||||||
|
copy(buf[2:], ipad)
|
||||||
|
copy(buf[2+len(ipad):], opad)
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BinaryUnmarshal is used by bstore to restore the ipad/opad hash states.
|
||||||
|
func (c *CRAMMD5) UnmarshalBinary(buf []byte) error {
|
||||||
|
if len(buf) == 0 {
|
||||||
|
*c = CRAMMD5{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(buf) < 2 {
|
||||||
|
return fmt.Errorf("short buffer")
|
||||||
|
}
|
||||||
|
ipadlen := int(uint16(buf[0])<<8 | uint16(buf[1])<<0)
|
||||||
|
if len(buf) < 2+ipadlen {
|
||||||
|
return fmt.Errorf("buffer too short for ipadlen")
|
||||||
|
}
|
||||||
|
ipad := md5.New()
|
||||||
|
opad := md5.New()
|
||||||
|
if err := ipad.(encoding.BinaryUnmarshaler).UnmarshalBinary(buf[2 : 2+ipadlen]); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal ipad: %v", err)
|
||||||
|
}
|
||||||
|
if err := opad.(encoding.BinaryUnmarshaler).UnmarshalBinary(buf[2+ipadlen:]); err != nil {
|
||||||
|
return fmt.Errorf("unmarshal opad: %v", err)
|
||||||
|
}
|
||||||
|
*c = CRAMMD5{ipad, opad}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password holds credentials in various forms, for logging in with SMTP/IMAP.
|
||||||
type Password struct {
|
type Password struct {
|
||||||
Hash string
|
Hash string // bcrypt hash for IMAP LOGIN and SASL PLAIN authentication.
|
||||||
SCRAMSHA1 SCRAM
|
CRAMMD5 CRAMMD5 // For SASL CRAM-MD5.
|
||||||
SCRAMSHA256 SCRAM
|
SCRAMSHA1 SCRAM // For SASL SCRAM-SHA-1.
|
||||||
|
SCRAMSHA256 SCRAM // For SASL SCRAM-SHA-256.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subjectpass holds the secret key used to sign subjectpass tokens.
|
// Subjectpass holds the secret key used to sign subjectpass tokens.
|
||||||
|
@ -615,6 +675,31 @@ func (a *Account) SetPassword(password string) error {
|
||||||
var pw Password
|
var pw Password
|
||||||
pw.Hash = string(hash)
|
pw.Hash = string(hash)
|
||||||
|
|
||||||
|
// CRAM-MD5 calculates an HMAC-MD5, with the password as key, over a per-attempt
|
||||||
|
// unique text that includes a timestamp. HMAC performs two hashes. Both times, the
|
||||||
|
// first block is based on the key/password. We hash those first blocks now, and
|
||||||
|
// store the hash state in the database. When we actually authenticate, we'll
|
||||||
|
// complete the HMAC by hashing only the text. We cannot store crypto/hmac's hash,
|
||||||
|
// because it does not expose its internal state and isn't a BinaryMarshaler.
|
||||||
|
// ../rfc/2104:121
|
||||||
|
pw.CRAMMD5.Ipad = md5.New()
|
||||||
|
pw.CRAMMD5.Opad = md5.New()
|
||||||
|
key := []byte(password)
|
||||||
|
if len(key) > 64 {
|
||||||
|
t := md5.Sum(key)
|
||||||
|
key = t[:]
|
||||||
|
}
|
||||||
|
ipad := make([]byte, md5.BlockSize)
|
||||||
|
opad := make([]byte, md5.BlockSize)
|
||||||
|
copy(ipad, key)
|
||||||
|
copy(opad, key)
|
||||||
|
for i := range ipad {
|
||||||
|
ipad[i] ^= 0x36
|
||||||
|
opad[i] ^= 0x5c
|
||||||
|
}
|
||||||
|
pw.CRAMMD5.Ipad.Write(ipad)
|
||||||
|
pw.CRAMMD5.Opad.Write(opad)
|
||||||
|
|
||||||
pw.SCRAMSHA1.Salt = scram.MakeRandom()
|
pw.SCRAMSHA1.Salt = scram.MakeRandom()
|
||||||
pw.SCRAMSHA1.Iterations = 2 * 4096
|
pw.SCRAMSHA1.Iterations = 2 * 4096
|
||||||
pw.SCRAMSHA1.SaltedPassword = scram.SaltPassword(sha1.New, password, pw.SCRAMSHA1.Salt, pw.SCRAMSHA1.Iterations)
|
pw.SCRAMSHA1.SaltedPassword = scram.SaltPassword(sha1.New, password, pw.SCRAMSHA1.Salt, pw.SCRAMSHA1.Iterations)
|
||||||
|
|
Loading…
Reference in a new issue