add scram-sha-256 for smtp

similar to imap. the code should be merged.
this also reads the abort-line after authentication failure.
This commit is contained in:
Mechiel Lukkien 2023-01-31 00:22:26 +01:00
parent b40bb257d7
commit 020d0bb0fb
No known key found for this signature in database
5 changed files with 128 additions and 17 deletions

View file

@ -90,6 +90,11 @@ func TestAuthenticateSCRAMSHA256(t *testing.T) {
} else if err == nil || !errors.Is(err, serverFinalError) { } else if err == nil || !errors.Is(err, serverFinalError) {
t.Fatalf("server final, got err %#v, expected %#v", err, serverFinalError) t.Fatalf("server final, got err %#v, expected %#v", err, serverFinalError)
} }
if serverFinalError != nil {
tc.writelinef("*")
} else {
tc.writelinef("")
}
_, result, err := tc.client.Response() _, result, err := tc.client.Response()
tc.check(err, "read response") tc.check(err, "read response")
if string(result.Status) != strings.ToUpper(status) { if string(result.Status) != strings.ToUpper(status) {

View file

@ -1370,6 +1370,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
case "SCRAM-SHA-256": case "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
authVariant = "scram-sha-256" authVariant = "scram-sha-256"
@ -1409,9 +1410,6 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
xcheckf(err, "read tx") xcheckf(err, "read tx")
}) })
s1, err := ss.ServerFirst(password.SCRAMSHA256.Iterations, password.SCRAMSHA256.Salt) s1, err := ss.ServerFirst(password.SCRAMSHA256.Iterations, password.SCRAMSHA256.Salt)
if err != nil {
xsyntaxErrorf("server first: %w", err)
}
xcheckf(err, "scram first server step") xcheckf(err, "scram first server step")
c.writelinef("+ %s", base64.StdEncoding.EncodeToString([]byte(s1))) c.writelinef("+ %s", base64.StdEncoding.EncodeToString([]byte(s1)))
c2 := xreadContinuation() c2 := xreadContinuation()
@ -1420,6 +1418,7 @@ func (c *conn) cmdAuthenticate(tag, cmd string, p *parser) {
c.writelinef("+ %s", base64.StdEncoding.EncodeToString([]byte(s3))) c.writelinef("+ %s", base64.StdEncoding.EncodeToString([]byte(s3)))
} }
if err != nil { if err != nil {
c.readline(false) // Should be "*" for cancellation.
if errors.Is(err, scram.ErrInvalidProof) { if errors.Is(err, scram.ErrInvalidProof) {
authResult = "badcreds" authResult = "badcreds"
xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials") xusercodeErrorf("AUTHENTICATIONFAILED", "bad credentials")
@ -2779,7 +2778,7 @@ func (c *conn) linkOrCopyFile(dst, src string) error {
if df != nil { if df != nil {
err = os.Remove(df.Name()) err = os.Remove(df.Name())
c.xsanity(err, "removing unfinished dst file") c.xsanity(err, "removing unfinished dst file")
df.Close() err = df.Close()
c.xsanity(err, "closing unfinished dst file") c.xsanity(err, "closing unfinished dst file")
} }
}() }()

View file

@ -32,9 +32,9 @@ var (
C504ParamNotImpl = 504 C504ParamNotImpl = 504
C521HostNoMail = 521 // ../rfc/7504:179 C521HostNoMail = 521 // ../rfc/7504:179
C530SecurityRequired = 530 // ../rfc/3207:148 ../rfc/4954:623 C530SecurityRequired = 530 // ../rfc/3207:148 ../rfc/4954:623
C538EncReqForAuth = 538 // ../rfc/4954:630
C534AuthMechWeak = 534 // ../rfc/4954:593 C534AuthMechWeak = 534 // ../rfc/4954:593
C535AuthBadCreds = 535 // ../rfc/4954:600 C535AuthBadCreds = 535 // ../rfc/4954:600
C538EncReqForAuth = 538 // ../rfc/4954:630
C550MailboxUnavail = 550 C550MailboxUnavail = 550
C551UserNotLocal = 551 C551UserNotLocal = 551
C552MailboxFull = 552 C552MailboxFull = 552

View file

@ -139,6 +139,21 @@ func (p *parser) takefn1(what string, fn func(c rune, i int) bool) string {
return p.remainder() return p.remainder()
} }
func (p *parser) takefn1case(what string, fn func(c rune, i int) bool) string {
if p.empty() {
p.xerrorf("need at least one char for %s", what)
}
for i, c := range p.orig[p.o:] {
if !fn(c, i) {
if i == 0 {
p.xerrorf("expected at least one char for %s", what)
}
return p.xtaken(i)
}
}
return p.remainder()
}
func (p *parser) takefn(fn func(c rune, i int) bool) string { func (p *parser) takefn(fn func(c rune, i int) bool) string {
for i, c := range p.upper[p.o:] { for i, c := range p.upper[p.o:] {
if !fn(c, i) { if !fn(c, i) {
@ -413,7 +428,7 @@ func (p *parser) xnumber(maxDigits int) int64 {
// sasl mechanism, for AUTH command. // sasl mechanism, for AUTH command.
// ../rfc/4422:436 // ../rfc/4422:436
func (p *parser) xsaslMech() string { func (p *parser) xsaslMech() string {
return p.takefn1("sasl-mech", func(c rune, i int) bool { return p.takefn1case("sasl-mech", func(c rune, i int) bool {
return i < 20 && (c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_') return i < 20 && (c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_')
}) })
} }

View file

@ -21,6 +21,8 @@ import (
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/mjl-/bstore"
"github.com/mjl-/mox/config" "github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc" "github.com/mjl-/mox/dmarc"
@ -36,6 +38,7 @@ import (
"github.com/mjl-/mox/moxvar" "github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/publicsuffix" "github.com/mjl-/mox/publicsuffix"
"github.com/mjl-/mox/queue" "github.com/mjl-/mox/queue"
"github.com/mjl-/mox/scram"
"github.com/mjl-/mox/smtp" "github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/spf" "github.com/mjl-/mox/spf"
"github.com/mjl-/mox/store" "github.com/mjl-/mox/store"
@ -659,10 +662,9 @@ func (c *conn) cmdHello(p *parser, ehlo bool) {
c.bwritelinef("250-STARTTLS") c.bwritelinef("250-STARTTLS")
} }
if c.submission { if c.submission {
// todo future: implement SCRAM-SHA-256.
// ../rfc/4954:123 // ../rfc/4954:123
if c.tls || !c.requireTLSForAuth { if c.tls || !c.requireTLSForAuth {
c.bwritelinef("250-AUTH PLAIN") c.bwritelinef("250-AUTH PLAIN SCRAM-SHA-256")
} else { } else {
c.bwritelinef("250-AUTH ") c.bwritelinef("250-AUTH ")
} }
@ -757,16 +759,8 @@ func (c *conn) cmdAuth(p *parser) {
// ../rfc/4954:699 // ../rfc/4954:699
p.xspace() p.xspace()
mech := p.xsaslMech() mech := p.xsaslMech()
switch mech {
case "PLAIN":
authVariant = "plain"
// ../rfc/4954:343
// ../rfc/4954:326
if !c.tls && c.requireTLSForAuth {
xsmtpUserErrorf(smtp.C538EncReqForAuth, smtp.SePol7EncReqForAuth11, "authentication requires tls")
}
xreadInitial := func() []byte {
var auth string var auth string
if p.empty() { if p.empty() {
c.writelinef("%d ", smtp.C334ContinueAuth) // ../rfc/4954:205 c.writelinef("%d ", smtp.C334ContinueAuth) // ../rfc/4954:205
@ -793,6 +787,34 @@ func (c *conn) cmdAuth(p *parser) {
// ../rfc/4954:235 // ../rfc/4954:235
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Syntax2, "invalid base64: %s", err) xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Syntax2, "invalid base64: %s", err)
} }
return buf
}
xreadContinuation := func() []byte {
line := c.readline()
if line == "*" {
authResult = "aborted"
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Other0, "authentication aborted")
}
buf, err := base64.StdEncoding.DecodeString(line)
if err != nil {
// ../rfc/4954:235
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5Syntax2, "invalid base64: %s", err)
}
return buf
}
switch mech {
case "PLAIN":
authVariant = "plain"
// ../rfc/4954:343
// ../rfc/4954:326
if !c.tls && c.requireTLSForAuth {
xsmtpUserErrorf(smtp.C538EncReqForAuth, smtp.SePol7EncReqForAuth11, "authentication requires tls")
}
buf := xreadInitial()
plain := bytes.Split(buf, []byte{0}) plain := bytes.Split(buf, []byte{0})
if len(plain) != 3 { if len(plain) != 3 {
xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "auth data should have 3 nul-separated tokens, got %d", len(plain)) xsmtpUserErrorf(smtp.C501BadParamSyntax, smtp.SeProto5BadParams4, "auth data should have 3 nul-separated tokens, got %d", len(plain))
@ -819,6 +841,76 @@ 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 "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
authVariant = "scram-sha-256"
c0 := xreadInitial()
ss, err := scram.NewServer(c0)
xcheckf(err, "starting scram")
c.log.Info("scram auth", mlog.Field("authentication", ss.Authentication))
acc, _, err := store.OpenEmail(ss.Authentication)
if err != nil {
// todo: we could continue scram with a generated salt, deterministically generated
// from the username. that way we don't have to store anything but attackers cannot
// learn if an account exists. same for absent scram saltedpassword below.
xsmtpUserErrorf(smtp.C454TempAuthFail, smtp.SeSys3Other0, "scram not possible")
}
defer func() {
if acc != nil {
err := acc.Close()
if err != nil {
c.log.Errorx("closing account", err)
}
}
}()
if ss.Authorization != "" && ss.Authorization != ss.Authentication {
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "authentication with authorization for different user not supported")
}
var password store.Password
acc.WithRLock(func() {
err := acc.DB.Read(func(tx *bstore.Tx) error {
password, err = bstore.QueryTx[store.Password](tx).Get()
xsc := password.SCRAMSHA256
if err == bstore.ErrAbsent || err == nil && (len(xsc.Salt) == 0 || xsc.Iterations == 0 || len(xsc.SaltedPassword) == 0) {
xsmtpUserErrorf(smtp.C454TempAuthFail, smtp.SeSys3Other0, "scram not possible")
}
xcheckf(err, "fetching credentials")
return err
})
xcheckf(err, "read tx")
})
s1, err := ss.ServerFirst(password.SCRAMSHA256.Iterations, password.SCRAMSHA256.Salt)
xcheckf(err, "scram first server step")
c.writelinef("%d %s", smtp.C334ContinueAuth, base64.StdEncoding.EncodeToString([]byte(s1))) // ../rfc/4954:187
c2 := xreadContinuation()
s3, err := ss.Finish(c2, password.SCRAMSHA256.SaltedPassword)
if len(s3) > 0 {
c.writelinef("%d %s", smtp.C334ContinueAuth, base64.StdEncoding.EncodeToString([]byte(s3))) // ../rfc/4954:187
}
if err != nil {
c.readline() // Should be "*" for cancellation.
if errors.Is(err, scram.ErrInvalidProof) {
authResult = "badcreds"
xsmtpUserErrorf(smtp.C535AuthBadCreds, smtp.SePol7AuthBadCreds8, "bad credentials")
}
xcheckf(err, "server final")
}
// Client must still respond, but there is nothing to say. See ../rfc/9051:6221
// The message should be empty. todo: should we require it is empty?
xreadContinuation()
authResult = "ok"
c.authFailed = 0
c.account = acc
acc = nil // Cancel cleanup.
c.username = ss.Authentication
// ../rfc/4954:276
c.writecodeline(smtp.C235AuthSuccess, smtp.SePol7Other0, "nice", nil)
default: default:
// todo future: implement scram-sha-256 ../rfc/7677 // todo future: implement scram-sha-256 ../rfc/7677
// todo future: possibly implement cram-md5, at least where we allow PLAIN. ../rfc/4954:348 // todo future: possibly implement cram-md5, at least where we allow PLAIN. ../rfc/4954:348