Compare commits

..

6 commits

Author SHA1 Message Date
d4c6cb506c
Add SCRAM authentication tests to smtp package
Added comprehensive unit tests for SCRAM-SHA-1, SCRAM-SHA-256, and their PLUS variants. Implemented a test server to simulate various SCRAM authentication scenarios and validate both success and failure cases.
2024-11-08 16:53:09 +01:00
c656226fd3
Add XOAuth2 authentication tests to SMTP package
Introduces two tests for XOAuth2 authentication in the SMTP package. The first test ensures successful authentication with valid credentials, while the second test verifies that authentication fails with incorrect settings.
2024-11-08 15:51:17 +01:00
1af17f14e1
Add cleanup to close client connections in tests
This commit enhances the cleanup process in the SMTP tests by adding t.Cleanup to close client connections. Additionally, it rewrites the TestXOAuth2 function to include more detailed sub-tests, enhancing test granularity and readability.
2024-11-08 15:11:47 +01:00
7b8a24f34a
Merge branch 'main' into smtp-client-tests 2024-11-08 14:42:58 +01:00
edc3de5484
Merge pull request #358 from wneessen/dependabot/go_modules/golang.org/x/crypto-0.29.0
Bump golang.org/x/crypto from 0.28.0 to 0.29.0
2024-11-08 14:34:57 +01:00
dependabot[bot]
62ea3f56af
Bump golang.org/x/crypto from 0.28.0 to 0.29.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.28.0 to 0.29.0.
- [Commits](https://github.com/golang/crypto/compare/v0.28.0...v0.29.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-08 13:31:55 +00:00
3 changed files with 564 additions and 106 deletions

4
go.mod
View file

@ -7,6 +7,6 @@ module github.com/wneessen/go-mail
go 1.16
require (
golang.org/x/crypto v0.28.0
golang.org/x/text v0.19.0
golang.org/x/crypto v0.29.0
golang.org/x/text v0.20.0
)

14
go.sum
View file

@ -5,8 +5,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@ -26,7 +26,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -37,7 +37,7 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -46,7 +46,7 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.25.0/go.mod h1:RPyXicDX+6vLxogjjRxjgD2TKtmAO6NZBsBRfrOLu7M=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
@ -55,8 +55,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

View file

@ -17,14 +17,22 @@ import (
"bufio"
"bytes"
"context"
"crypto/hmac"
"crypto/sha1"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"hash"
"io"
"net"
"strings"
"sync/atomic"
"testing"
"time"
"golang.org/x/crypto/pbkdf2"
)
const (
@ -382,6 +390,11 @@ func TestPlainAuth(t *testing.T) {
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
t.Cleanup(func() {
if err = client.Close(); err != nil {
t.Errorf("failed to close client connection: %s", err)
}
})
if err = client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to test server: %s", err)
}
@ -530,6 +543,11 @@ func TestPlainAuth_noEnc(t *testing.T) {
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
t.Cleanup(func() {
if err = client.Close(); err != nil {
t.Errorf("failed to close client connection: %s", err)
}
})
if err = client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to test server: %s", err)
}
@ -674,6 +692,11 @@ func TestLoginAuth(t *testing.T) {
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
t.Cleanup(func() {
if err = client.Close(); err != nil {
t.Errorf("failed to close client connection: %s", err)
}
})
if err = client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to test server: %s", err)
}
@ -815,6 +838,11 @@ func TestLoginAuth_noEnc(t *testing.T) {
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
t.Cleanup(func() {
if err = client.Close(); err != nil {
t.Errorf("failed to close client connection: %s", err)
}
})
if err = client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to test server: %s", err)
}
@ -848,12 +876,33 @@ func TestLoginAuth_noEnc(t *testing.T) {
})
}
/*
func TestXOAuth2OK(t *testing.T) {
func TestXOAuth2Auth(t *testing.T) {
t.Run("XOAuth2 authentication all steps", func(t *testing.T) {
auth := XOAuth2Auth("user", "token")
proto, toserver, err := auth.Start(&ServerInfo{Name: "servername", TLS: true})
if err != nil {
t.Fatalf("failed to start XOAuth2 authentication: %s", err)
}
if proto != "XOAUTH2" {
t.Errorf("expected protocol to be XOAUTH2, got: %q", proto)
}
expected := []byte("user=user\x01auth=Bearer token\x01\x01")
if !bytes.Equal(expected, toserver) {
t.Errorf("expected server response to be: %q, got: %q", expected, toserver)
}
resp, err := auth.Next([]byte("nonsense"), true)
if err != nil {
t.Errorf("failed on first server challange: %s", err)
}
if !bytes.Equal([]byte(""), resp) {
t.Errorf("expected server response to be empty, got: %q", resp)
}
resp, err = auth.Next([]byte("nonsense"), false)
if err != nil {
t.Errorf("failed on first server challange: %s", err)
}
})
t.Run("XOAuth2 succeeds with faker", func(t *testing.T) {
server := []string{
"220 Fake server ready ESMTP",
"250-fake.server",
@ -870,30 +919,28 @@ func TestXOAuth2OK(t *testing.T) {
strings.NewReader(strings.Join(server, "\r\n")),
&wrote,
}
c, err := NewClient(fake, "fake.host")
client, err := NewClient(fake, "fake.host")
if err != nil {
t.Fatalf("NewClient: %v", err)
t.Fatalf("failed to create client on faker server: %s", err)
}
defer func() {
if err := c.Close(); err != nil {
t.Errorf("failed to close client: %s", err)
t.Cleanup(func() {
if err = client.Close(); err != nil {
t.Errorf("failed to close client connection: %s", err)
}
}()
})
auth := XOAuth2Auth("user", "token")
err = c.Auth(auth)
if err != nil {
t.Fatalf("XOAuth2 error: %v", err)
if err = client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to faker server: %s", err)
}
// the Next method returns a nil response. It must not be sent.
// The client request must end with the authentication.
if !strings.HasSuffix(wrote.String(), "AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=\r\n") {
t.Fatalf("got %q; want AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=\r\n", wrote.String())
}
}
func TestXOAuth2Error(t *testing.T) {
})
t.Run("XOAuth2 fails with faker", func(t *testing.T) {
serverResp := []string{
"220 Fake server ready ESMTP",
"250-fake.server",
@ -912,35 +959,271 @@ func TestXOAuth2Error(t *testing.T) {
strings.NewReader(strings.Join(serverResp, "\r\n")),
&wrote,
}
c, err := NewClient(fake, "fake.host")
client, err := NewClient(fake, "fake.host")
if err != nil {
t.Fatalf("NewClient: %v", err)
t.Fatalf("failed to create client on faker server: %s", err)
}
defer func() {
if err := c.Close(); err != nil {
t.Errorf("failed to close client: %s", err)
t.Cleanup(func() {
if err = client.Close(); err != nil {
t.Errorf("failed to close client connection: %s", err)
}
}()
})
auth := XOAuth2Auth("user", "token")
err = c.Auth(auth)
if err == nil {
t.Fatal("expected auth error, got nil")
if err = client.Auth(auth); err == nil {
t.Errorf("expected authentication to fail")
}
client := strings.Split(wrote.String(), "\r\n")
if len(client) != 5 {
t.Fatalf("unexpected number of client requests got %d; want 5", len(client))
resp := strings.Split(wrote.String(), "\r\n")
if len(resp) != 5 {
t.Fatalf("unexpected number of client requests got %d; want 5", len(resp))
}
if client[1] != "AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=" {
t.Fatalf("got %q; want AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=", client[1])
if resp[1] != "AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=" {
t.Fatalf("got %q; want AUTH XOAUTH2 dXNlcj11c2VyAWF1dGg9QmVhcmVyIHRva2VuAQE=", resp[1])
}
// the Next method returns an empty response. It must be sent
if client[2] != "" {
t.Fatalf("got %q; want empty response", client[2])
if resp[2] != "" {
t.Fatalf("got %q; want empty response", resp[2])
}
})
t.Run("XOAuth2 authentication on test server succeeds", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH XOAUTH2\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FeatureSet: featureSet,
ListenPort: serverPort},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
auth := XOAuth2Auth("user", "token")
client, err := Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
t.Cleanup(func() {
if err = client.Close(); err != nil {
t.Errorf("failed to close client connection: %s", err)
}
})
if err = client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to test server: %s", err)
}
})
t.Run("XOAuth2 authentication on test server fails", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := "250-AUTH XOAUTH2\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8"
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
FailOnAuth: true,
FeatureSet: featureSet,
ListenPort: serverPort},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
auth := XOAuth2Auth("user", "token")
client, err := Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
if err = client.Auth(auth); err == nil {
t.Errorf("expected authentication to fail")
}
})
}
func TestScramAuth(t *testing.T) {
tests := []struct {
name string
tls bool
authString string
hash func() hash.Hash
isPlus bool
}{
{"SCRAM-SHA-1 (no TLS)", false, "SCRAM-SHA-1", sha1.New, false},
{"SCRAM-SHA-256 (no TLS)", false, "SCRAM-SHA-256", sha256.New, false},
{"SCRAM-SHA-1 (with TLS)", true, "SCRAM-SHA-1", sha1.New, false},
{"SCRAM-SHA-256 (with TLS)", true, "SCRAM-SHA-256", sha256.New, false},
{"SCRAM-SHA-1-PLUS", true, "SCRAM-SHA-1-PLUS", sha1.New, true},
{"SCRAM-SHA-256-PLUS", true, "SCRAM-SHA-256-PLUS", sha256.New, true},
}
for _, tt := range tests {
t.Run(tt.name+" succeeds on test server", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := fmt.Sprintf("250-AUTH %s\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8", tt.authString)
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
TestSCRAM: true,
HashFunc: tt.hash,
FeatureSet: featureSet,
ListenPort: serverPort,
SSLListener: tt.tls,
IsSCRAMPlus: tt.isPlus,
},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
var client *Client
switch tt.tls {
case true:
cert, err := tls.X509KeyPair(localhostCert, localhostKey)
if err != nil {
fmt.Printf("error creating TLS cert: %s", err)
return
}
tlsConfig := tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true}
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", TestServerAddr, serverPort), &tlsConfig)
if err != nil {
t.Fatalf("failed to dial TLS server: %v", err)
}
client, err = NewClient(conn, TestServerAddr)
case false:
var err error
client, err = Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
}
t.Cleanup(func() {
if err := client.Close(); err != nil {
t.Errorf("failed to close client connection: %s", err)
}
})
var auth Auth
switch tt.authString {
case "SCRAM-SHA-1":
auth = ScramSHA1Auth("username", "password")
case "SCRAM-SHA-256":
auth = ScramSHA256Auth("username", "password")
case "SCRAM-SHA-1-PLUS":
tlsConnState, err := client.GetTLSConnectionState()
if err != nil {
t.Fatalf("failed to get TLS connection state: %s", err)
}
auth = ScramSHA1PlusAuth("username", "password", tlsConnState)
case "SCRAM-SHA-256-PLUS":
tlsConnState, err := client.GetTLSConnectionState()
if err != nil {
t.Fatalf("failed to get TLS connection state: %s", err)
}
auth = ScramSHA256PlusAuth("username", "password", tlsConnState)
default:
t.Fatalf("unexpected auth string: %s", tt.authString)
}
if err := client.Auth(auth); err != nil {
t.Errorf("failed to authenticate to test server: %s", err)
}
})
t.Run(tt.name+" fails on test server", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
PortAdder.Add(1)
serverPort := int(TestServerPortBase + PortAdder.Load())
featureSet := fmt.Sprintf("250-AUTH %s\r\n250-8BITMIME\r\n250-DSN\r\n250 SMTPUTF8", tt.authString)
go func() {
if err := simpleSMTPServer(ctx, t, &serverProps{
TestSCRAM: true,
HashFunc: tt.hash,
FeatureSet: featureSet,
ListenPort: serverPort,
SSLListener: tt.tls,
IsSCRAMPlus: tt.isPlus,
},
); err != nil {
t.Errorf("failed to start test server: %s", err)
return
}
}()
time.Sleep(time.Millisecond * 30)
var client *Client
switch tt.tls {
case true:
cert, err := tls.X509KeyPair(localhostCert, localhostKey)
if err != nil {
fmt.Printf("error creating TLS cert: %s", err)
return
}
tlsConfig := tls.Config{Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true}
conn, err := tls.Dial("tcp", fmt.Sprintf("%s:%d", TestServerAddr, serverPort), &tlsConfig)
if err != nil {
t.Fatalf("failed to dial TLS server: %v", err)
}
client, err = NewClient(conn, TestServerAddr)
case false:
var err error
client, err = Dial(fmt.Sprintf("%s:%d", TestServerAddr, serverPort))
if err != nil {
t.Fatalf("failed to connect to test server: %s", err)
}
}
var auth Auth
switch tt.authString {
case "SCRAM-SHA-1":
auth = ScramSHA1Auth("invalid", "password")
case "SCRAM-SHA-256":
auth = ScramSHA256Auth("invalid", "password")
case "SCRAM-SHA-1-PLUS":
tlsConnState, err := client.GetTLSConnectionState()
if err != nil {
t.Fatalf("failed to get TLS connection state: %s", err)
}
auth = ScramSHA1PlusAuth("invalid", "password", tlsConnState)
case "SCRAM-SHA-256-PLUS":
tlsConnState, err := client.GetTLSConnectionState()
if err != nil {
t.Fatalf("failed to get TLS connection state: %s", err)
}
auth = ScramSHA256PlusAuth("invalid", "password", tlsConnState)
default:
t.Fatalf("unexpected auth string: %s", tt.authString)
}
if err := client.Auth(auth); err == nil {
t.Error("expected authentication to fail")
}
})
}
t.Run("ScramAuth_Next with nonsense parameter", func(t *testing.T) {
auth := ScramSHA1Auth("username", "password")
_, err := auth.Next([]byte("x=nonsense"), true)
if err == nil {
t.Fatal("expected authentication to fail")
}
if !errors.Is(err, ErrUnexpectedServerResponse) {
t.Errorf("expected ErrUnexpectedServerResponse, got %s", err)
}
})
}
/*
func TestAuthSCRAMSHA1_OK(t *testing.T) {
hostname := "127.0.0.1"
port := "2585"
@ -1217,17 +1500,6 @@ func (toServerEmptyAuth) Next(_ []byte, _ bool) (toServer []byte, err error) {
panic("unexpected call")
}
type faker struct {
io.ReadWriter
}
func (f faker) Close() error { return nil }
func (f faker) LocalAddr() net.Addr { return nil }
func (f faker) RemoteAddr() net.Addr { return nil }
func (f faker) SetDeadline(time.Time) error { return nil }
func (f faker) SetReadDeadline(time.Time) error { return nil }
func (f faker) SetWriteDeadline(time.Time) error { return nil }
func TestBasic(t *testing.T) {
server := strings.Join(strings.Split(basicServer, "\n"), "\r\n")
client := strings.Join(strings.Split(basicClient, "\n"), "\r\n")
@ -3016,6 +3288,19 @@ func startSMTPServer(tlsServer bool, hostname, port string, h func() hash.Hash)
*/
// faker is a struct embedding io.ReadWriter to simulate network connections for testing purposes.
type faker struct {
io.ReadWriter
}
func (f faker) Close() error { return nil }
func (f faker) LocalAddr() net.Addr { return nil }
func (f faker) RemoteAddr() net.Addr { return nil }
func (f faker) SetDeadline(time.Time) error { return nil }
func (f faker) SetReadDeadline(time.Time) error { return nil }
func (f faker) SetWriteDeadline(time.Time) error { return nil }
// testingKey replaces the substring "TESTING KEY" with "PRIVATE KEY" in the given string s.
func testingKey(s string) string { return strings.ReplaceAll(s, "TESTING KEY", "PRIVATE KEY") }
@ -3034,8 +3319,11 @@ type serverProps struct {
FeatureSet string
ListenPort int
SSLListener bool
IsSCRAMPlus bool
IsTLS bool
SupportDSN bool
TestSCRAM bool
HashFunc func() hash.Hash
}
// simpleSMTPServer starts a simple TCP server that resonds to SMTP commands.
@ -3173,6 +3461,22 @@ func handleTestServerConnection(connection net.Conn, t *testing.T, props *server
writeLine("535 5.7.8 Error: authentication failed")
break
}
if props.TestSCRAM {
parts := strings.Split(data, " ")
authMechanism := parts[1]
if authMechanism != "SCRAM-SHA-1" && authMechanism != "SCRAM-SHA-256" &&
authMechanism != "SCRAM-SHA-1-PLUS" && authMechanism != "SCRAM-SHA-256-PLUS" {
writeLine("504 Unrecognized authentication mechanism")
break
}
scram := &testSCRAMSMTP{
tlsServer: props.IsSCRAMPlus,
h: props.HashFunc,
}
writeLine("334 ")
scram.handleSCRAMAuth(connection)
break
}
writeLine("235 2.7.0 Authentication successful")
case strings.EqualFold(data, "DATA"):
if props.FailOnDataInit {
@ -3242,3 +3546,157 @@ func handleTestServerConnection(connection net.Conn, t *testing.T, props *server
}
}
}
// testSCRAMSMTP represents a part of the test server for SCRAM-based SMTP authentication.
// It does not do any acutal computation of the challenges but verifies that the expected
// fields are present. We have actual real authentication tests for all SCRAM modes in the
// go-mail client_test.go
type testSCRAMSMTP struct {
authMechanism string
nonce string
h func() hash.Hash
tlsServer bool
}
func (s *testSCRAMSMTP) handleSCRAMAuth(conn net.Conn) {
reader := bufio.NewReader(conn)
writer := bufio.NewWriter(conn)
writeLine := func(data string) error {
_, err := writer.WriteString(data + "\r\n")
if err != nil {
return fmt.Errorf("unable to write line: %w", err)
}
return writer.Flush()
}
var authMsg string
data, err := reader.ReadString('\n')
if err != nil {
_ = writeLine("535 Authentication failed")
return
}
data = strings.TrimSpace(data)
decodedMessage, err := base64.StdEncoding.DecodeString(data)
if err != nil {
_ = writeLine("535 Authentication failed")
return
}
splits := strings.Split(string(decodedMessage), ",")
if len(splits) != 4 {
_ = writeLine("535 Authentication failed - expected 4 parts")
return
}
if !s.tlsServer && splits[0] != "n" {
_ = writeLine("535 Authentication failed - expected n to be in the first part")
return
}
if s.tlsServer && !strings.HasPrefix(splits[0], "p=") {
_ = writeLine("535 Authentication failed - expected p= to be in the first part")
return
}
if splits[2] != "n=username" {
_ = writeLine("535 Authentication failed - expected n=username to be in the third part")
return
}
if !strings.HasPrefix(splits[3], "r=") {
_ = writeLine("535 Authentication failed - expected r= to be in the fourth part")
return
}
authMsg = splits[2] + "," + splits[3]
clientNonce := s.extractNonce(string(decodedMessage))
if clientNonce == "" {
_ = writeLine("535 Authentication failed")
return
}
s.nonce = clientNonce + "server_nonce"
serverFirstMessage := fmt.Sprintf("r=%s,s=%s,i=4096", s.nonce,
base64.StdEncoding.EncodeToString([]byte("salt")))
_ = writeLine(fmt.Sprintf("334 %s", base64.StdEncoding.EncodeToString([]byte(serverFirstMessage))))
authMsg = authMsg + "," + serverFirstMessage
data, err = reader.ReadString('\n')
if err != nil {
_ = writeLine("535 Authentication failed")
return
}
data = strings.TrimSpace(data)
decodedFinalMessage, err := base64.StdEncoding.DecodeString(data)
if err != nil {
_ = writeLine("535 Authentication failed")
return
}
splits = strings.Split(string(decodedFinalMessage), ",")
if !s.tlsServer && splits[0] != "c=biws" {
_ = writeLine("535 Authentication failed - expected c=biws to be in the first part")
return
}
if s.tlsServer {
if !strings.HasPrefix(splits[0], "c=") {
_ = writeLine("535 Authentication failed - expected c= to be in the first part")
return
}
channelBind, err := base64.StdEncoding.DecodeString(splits[0][2:])
if err != nil {
_ = writeLine("535 Authentication failed - base64 channel bind is not valid - " + err.Error())
return
}
if !strings.HasPrefix(string(channelBind), "p=") {
_ = writeLine("535 Authentication failed - expected channel binding to start with p=-")
return
}
cbType := string(channelBind[2:])
if !strings.HasPrefix(cbType, "tls-unique") && !strings.HasPrefix(cbType, "tls-exporter") {
_ = writeLine("535 Authentication failed - expected channel binding type tls-unique or tls-exporter")
return
}
}
if !strings.HasPrefix(splits[1], "r=") {
_ = writeLine("535 Authentication failed - expected r to be in the second part")
return
}
if !strings.Contains(splits[1], "server_nonce") {
_ = writeLine("535 Authentication failed - expected server_nonce to be in the second part")
return
}
if !strings.HasPrefix(splits[2], "p=") {
_ = writeLine("535 Authentication failed - expected p to be in the third part")
return
}
authMsg = authMsg + "," + splits[0] + "," + splits[1]
saltedPwd := pbkdf2.Key([]byte("password"), []byte("salt"), 4096, s.h().Size(), s.h)
mac := hmac.New(s.h, saltedPwd)
mac.Write([]byte("Server Key"))
skey := mac.Sum(nil)
mac.Reset()
mac = hmac.New(s.h, skey)
mac.Write([]byte(authMsg))
ssig := mac.Sum(nil)
mac.Reset()
serverFinalMessage := fmt.Sprintf("v=%s", base64.StdEncoding.EncodeToString(ssig))
_ = writeLine(fmt.Sprintf("334 %s", base64.StdEncoding.EncodeToString([]byte(serverFinalMessage))))
_, err = reader.ReadString('\n')
if err != nil {
_ = writeLine("535 Authentication failed")
return
}
_ = writeLine("235 Authentication successful")
}
func (s *testSCRAMSMTP) extractNonce(message string) string {
parts := strings.Split(message, ",")
for _, part := range parts {
if strings.HasPrefix(part, "r=") {
return part[2:]
}
}
return ""
}