diff --git a/http/autoconf.go b/http/autoconf.go index 66c8d5a..d46d9bd 100644 --- a/http/autoconf.go +++ b/http/autoconf.go @@ -103,14 +103,14 @@ func autoconfHandle(l config.Listener) http.HandlerFunc { 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.Hostname = hostname.ASCII resp.EmailProvider.IncomingServer.Port = imapPort resp.EmailProvider.IncomingServer.SocketType = imapSocket resp.EmailProvider.IncomingServer.Username = email - resp.EmailProvider.IncomingServer.Authentication = "password-cleartext" + resp.EmailProvider.IncomingServer.Authentication = "password-encrypted" var smtpPort int var smtpSocket string @@ -133,7 +133,7 @@ func autoconfHandle(l config.Listener) http.HandlerFunc { resp.EmailProvider.OutgoingServer.Port = smtpPort resp.EmailProvider.OutgoingServer.SocketType = smtpSocket 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? 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. // User should create a DNS record: _autodiscover._tcp. IN SRV 0 0 443 > -// 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 -// an option to do "Autodiscover to detect server settings". Incoming TLS -// connections are all failing, with various errors. +// +// In practice, autodiscover does not seem to work wit microsoft clients. A +// connectivity test tool for outlook is available on +// 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 { return func(w http.ResponseWriter, r *http.Request) { 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 // 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 - // 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 imapSSL := "off" diff --git a/imapserver/authenticate_test.go b/imapserver/authenticate_test.go index 0dae24f..15c8b15 100644 --- a/imapserver/authenticate_test.go +++ b/imapserver/authenticate_test.go @@ -1,10 +1,13 @@ package imapserver import ( + "crypto/hmac" + "crypto/md5" "crypto/sha1" "crypto/sha256" "encoding/base64" "errors" + "fmt" "hash" "strings" "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.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() +} diff --git a/imapserver/server.go b/imapserver/server.go index 1d42bed..30315a6 100644 --- a/imapserver/server.go +++ b/imapserver/server.go @@ -39,6 +39,7 @@ import ( "bufio" "bytes" "context" + "crypto/md5" "crypto/sha1" "crypto/sha256" "crypto/tls" @@ -118,8 +119,9 @@ var ( // ID: ../rfc/2971 // AUTH=SCRAM-SHA-256: ../rfc/7677 ../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 -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 { cid int64 @@ -1392,6 +1394,71 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { c.username = authc 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": // 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 @@ -1438,6 +1505,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) { xscram = password.SCRAMSHA256 } 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") } xcheckf(err, "fetching credentials") diff --git a/main.go b/main.go index 0a2d275..55b8f00 100644 --- a/main.go +++ b/main.go @@ -876,8 +876,10 @@ func cmdSetaccountpassword(c *cmd) { c.params = "address" c.help = `Set new password an account. -The password is read from stdin. Its bcrypt hash and SCRAM-SHA-256 derivations -are stored in the accounts database. +The password is read from stdin. Secrets derived from the password, but not the +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. ` @@ -1958,7 +1960,7 @@ binary should be setgid that group: if !submitconf.STARTTLS { 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)))) mox.Conf.Static.HostnameDomain.ASCII = submitconf.LocalHostname client, err := smtpclient.New(ctx, mlog.New("sendmail"), conn, tlsMode, submitconf.Host, authLine) diff --git a/metrics/auth.go b/metrics/auth.go index baf3d53..9c7ba46 100644 --- a/metrics/auth.go +++ b/metrics/auth.go @@ -13,7 +13,7 @@ var ( }, []string{ "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 "result", // ok, baduser, badpassword, badcreds, error, aborted }, diff --git a/mox-/rand.go b/mox-/rand.go index ec088a9..4fd3ee9 100644 --- a/mox-/rand.go +++ b/mox-/rand.go @@ -9,10 +9,11 @@ import ( // NewRand returns a new PRNG seeded with random bytes from crypto/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) _, err := cryptorand.Read(buf) if err != nil { diff --git a/rfc/index.md b/rfc/index.md index 8e0ee07..e21a926 100644 --- a/rfc/index.md +++ b/rfc/index.md @@ -226,6 +226,8 @@ and many more, see http://sieve.info/documents # 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 4422 Simple Authentication and Security Layer (SASL) 4505 Anonymous Simple Authentication and Security Layer (SASL) Mechanism diff --git a/smtpserver/server.go b/smtpserver/server.go index be76222..3253262 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -5,6 +5,7 @@ import ( "bufio" "bytes" "context" + "crypto/md5" "crypto/rsa" "crypto/sha1" "crypto/sha256" @@ -682,7 +683,7 @@ func (c *conn) cmdHello(p *parser, ehlo bool) { if c.submission { // ../rfc/4954:123 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 { c.bwritelinef("250-AUTH ") } @@ -864,6 +865,75 @@ func (c *conn) cmdAuth(p *parser) { // ../rfc/4954:276 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": // 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 @@ -910,6 +980,7 @@ func (c *conn) cmdAuth(p *parser) { xscram = password.SCRAMSHA256 } 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") } xcheckf(err, "fetching credentials") @@ -947,8 +1018,6 @@ func (c *conn) cmdAuth(p *parser) { c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil) 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 xsmtpUserErrorf(smtp.C504ParamNotImpl, smtp.SeProto5BadParams4, "mechanism %s not supported", mech) } diff --git a/store/account.go b/store/account.go index 146eacc..c89c30f 100644 --- a/store/account.go +++ b/store/account.go @@ -24,11 +24,14 @@ package store import ( "context" + "crypto/md5" "crypto/sha1" "crypto/sha256" + "encoding" "encoding/json" "errors" "fmt" + "hash" "io" "os" "path/filepath" @@ -70,11 +73,68 @@ type SCRAM struct { 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 { - Hash string - SCRAMSHA1 SCRAM - SCRAMSHA256 SCRAM + Hash string // bcrypt hash for IMAP LOGIN and SASL PLAIN authentication. + CRAMMD5 CRAMMD5 // For SASL CRAM-MD5. + SCRAMSHA1 SCRAM // For SASL SCRAM-SHA-1. + SCRAMSHA256 SCRAM // For SASL SCRAM-SHA-256. } // Subjectpass holds the secret key used to sign subjectpass tokens. @@ -615,6 +675,31 @@ func (a *Account) SetPassword(password string) error { var pw Password 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.Iterations = 2 * 4096 pw.SCRAMSHA1.SaltedPassword = scram.SaltPassword(sha1.New, password, pw.SCRAMSHA1.Salt, pw.SCRAMSHA1.Iterations)