diff --git a/.reuse/dep5 b/.reuse/dep5 deleted file mode 100644 index 70261ce..0000000 --- a/.reuse/dep5 +++ /dev/null @@ -1,10 +0,0 @@ -Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ -Upstream-Name: go-mail -Upstream-Contact: Winni Neessen -Source: https://github.com/wneessen/go-mail - -# Sample paragraph, commented out: -# -# Files: src/* -# Copyright: $YEAR $NAME <$CONTACT> -# License: ... diff --git a/README.md b/README.md index 260b888..6a34055 100644 --- a/README.md +++ b/README.md @@ -18,33 +18,34 @@ SPDX-License-Identifier: CC0-1.0

go-mail logo

-The main idea of this library was to provide a simple interface to sending mails for +The main idea of this library was to provide a simple interface for sending mails to my [JS-Mailer](https://github.com/wneessen/js-mailer) project. It quickly evolved into a full-fledged mail library. -go-mail follows idiomatic Go style and best practice. It's only dependency is the Go Standard Library. It combines a lot -of functionality from the standard library to give easy and convenient access to mail and SMTP related tasks. +go-mail follows idiomatic Go style and best practice. It has a small dependency footprint by mainly relying on the +Go Standard Library and the Go extended packages. It combines a lot of functionality from the standard library to +give easy and convenient access to mail and SMTP related tasks. -Parts of this library (especially some parts of [msgwriter.go](msgwriter.go)) have been forked/ported from the -[go-mail/mail](https://github.com/go-mail/mail) respectively [go-gomail/gomail](https://github.com/go-gomail/gomail) -which both seems to not be maintained anymore. +In the early days, parts of this library (especially some parts of [msgwriter.go](msgwriter.go)) had been +forked/ported from [go-mail/mail](https://github.com/go-mail/mail) and respectively [go-gomail/gomail](https://github.com/go-gomail/gomail). Today +most of the ported code has been refactored. -The smtp package of go-mail is forked from the original Go stdlib's `net/smtp` and then extended by the go-mail -team. +The `smtp` package of go-mail has been forked from the original Go stdlib's `net/smtp` package and has then been extended +by the go-mail team to fit the packages needs (more SMTP Auth methods, logging, concurrency-safety, etc.). ## Features -Some of the features of this library: +Here are some highlights of go-mail's featureset: -* [X] Only Standard Library dependant +* [X] Very small dependency footprint (mainly Go Stdlib and Go extended packages) * [X] Modern, idiomatic Go * [X] Sane and secure defaults * [X] Explicit SSL/TLS support * [X] Implicit StartTLS support with different policies * [X] Makes use of contexts for a better control flow and timeout/cancelation handling -* [X] SMTP Auth support (LOGIN, PLAIN, CRAM-MD, XOAUTH2) +* [X] SMTP Auth support (LOGIN, PLAIN, CRAM-MD, XOAUTH2, SCRAM-SHA-1(-PLUS), SCRAM-SHA-256(-PLUS)) * [X] RFC5322 compliant mail address validation * [X] Support for common mail header field generation (Message-ID, Date, Bulk-Precedence, Priority, etc.) -* [X] Reusing the same SMTP connection to send multiple mails +* [X] Concurrency-safe reusing the same SMTP connection to send multiple mails * [X] Support for attachments and inline embeds (from file system, `io.Reader` or `embed.FS`) * [X] Support for different encodings * [X] Middleware support for 3rd-party libraries to alter mail messages @@ -99,15 +100,18 @@ We provide example code in both our GoDocs as well as on our official Website (s check out our [Getting started](https://go-mail.dev/getting-started/introduction/) guide. ## Authors/Contributors -go-mail was initially authored and developed by [Winni Neessen](https://github.com/wneessen/). +go-mail was initially created and developed by [Winni Neessen](https://github.com/wneessen/), but over time a lot of amazing people +contributed ot the project. Big thanks to all of them for improving the go-mail project (be it writing code, testing +code, reviewing code, writing documenation or helping to translate the website): -Big thanks to the following people, for contributing to the go-mail project (either in form of code or by -reviewing code, writing documenation or helping to translate the website): -* [Christian Vette](https://github.com/cvette) -* [Dhia Gharsallaoui](https://github.com/dhia-gharsallaoui) -* [inliquid](https://github.com/inliquid) -* [iwittkau](https://github.com/iwittkau) -* [James Elliott](https://github.com/james-d-elliott) -* [Maria Letta](https://github.com/MariaLetta) (designed the go-mail logo) -* [Nicola Murino](https://github.com/drakkan) -* [sters](https://github.com/sters) + + + + +A huge thank you also goes to [Maria Letta](https://github.com/MariaLetta) for designing our super cool go-mail logo! + +## Sponsors +We sincerely thank our amazing sponsors for their generous support! Your contributions do not go unnoticed and helps +keeping up the project! + +* [kolaente](https://github.com/kolaente) diff --git a/REUSE.toml b/REUSE.toml new file mode 100644 index 0000000..0bca544 --- /dev/null +++ b/REUSE.toml @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +# +# SPDX-License-Identifier: MIT + +version = 1 +SPDX-PackageName = "go-mail" +SPDX-PackageSupplier = "Winni Neessen " +SPDX-PackageDownloadLocation = "https://github.com/wneessen/go-mail" +annotations = [] diff --git a/auth.go b/auth.go index 4a59b14..f1dad86 100644 --- a/auth.go +++ b/auth.go @@ -28,6 +28,22 @@ const ( // SMTPAuthXOAUTH2 is the "XOAUTH2" SASL authentication mechanism. // https://developers.google.com/gmail/imap/xoauth2-protocol SMTPAuthXOAUTH2 SMTPAuthType = "XOAUTH2" + + // SMTPAuthSCRAMSHA1 represents the SCRAM-SHA-1 SMTP authentication mechanism + // https://datatracker.ietf.org/doc/html/rfc5802 + SMTPAuthSCRAMSHA1 SMTPAuthType = "SCRAM-SHA-1" + + // SMTPAuthSCRAMSHA1PLUS represents the "SCRAM-SHA-1-PLUS" authentication mechanism for SMTP. + // https://datatracker.ietf.org/doc/html/rfc5802 + SMTPAuthSCRAMSHA1PLUS SMTPAuthType = "SCRAM-SHA-1-PLUS" + + // SMTPAuthSCRAMSHA256 represents the SCRAM-SHA-256 authentication mechanism for SMTP. + // https://datatracker.ietf.org/doc/html/rfc7677 + SMTPAuthSCRAMSHA256 SMTPAuthType = "SCRAM-SHA-256" + + // SMTPAuthSCRAMSHA256PLUS represents the "SCRAM-SHA-256-PLUS" SMTP AUTH type. + // https://datatracker.ietf.org/doc/html/rfc7677 + SMTPAuthSCRAMSHA256PLUS SMTPAuthType = "SCRAM-SHA-256-PLUS" ) // SMTP Auth related static errors @@ -43,4 +59,16 @@ var ( // ErrXOauth2AuthNotSupported should be used if the target server does not support the "XOAUTH2" schema ErrXOauth2AuthNotSupported = errors.New("server does not support SMTP AUTH type: XOAUTH2") + + // ErrSCRAMSHA1AuthNotSupported should be used if the target server does not support the "SCRAM-SHA-1" schema + ErrSCRAMSHA1AuthNotSupported = errors.New("server does not support SMTP AUTH type: SCRAM-SHA-1") + + // ErrSCRAMSHA1PLUSAuthNotSupported should be used if the target server does not support the "SCRAM-SHA-1-PLUS" schema + ErrSCRAMSHA1PLUSAuthNotSupported = errors.New("server does not support SMTP AUTH type: SCRAM-SHA-1-PLUS") + + // ErrSCRAMSHA256AuthNotSupported should be used if the target server does not support the "SCRAM-SHA-256" schema + ErrSCRAMSHA256AuthNotSupported = errors.New("server does not support SMTP AUTH type: SCRAM-SHA-256") + + // ErrSCRAMSHA256PLUSAuthNotSupported should be used if the target server does not support the "SCRAM-SHA-256-PLUS" schema + ErrSCRAMSHA256PLUSAuthNotSupported = errors.New("server does not support SMTP AUTH type: SCRAM-SHA-256-PLUS") ) diff --git a/client.go b/client.go index 6557913..4d1b0b1 100644 --- a/client.go +++ b/client.go @@ -578,6 +578,7 @@ func (c *Client) SetPassword(password string) { // SetSMTPAuth overrides the current SMTP AUTH type setting with the given value func (c *Client) SetSMTPAuth(authtype SMTPAuthType) { c.smtpAuthType = authtype + c.smtpAuth = nil } // SetSMTPAuthCustom overrides the current SMTP AUTH setting with the given custom smtp.Auth @@ -748,7 +749,17 @@ func (c *Client) tls() error { return err } } - _, c.isEncrypted = c.smtpClient.TLSConnectionState() + tlsConnState, err := c.smtpClient.GetTLSConnectionState() + if err != nil { + switch { + case errors.Is(err, smtp.ErrNonTLSConnection): + c.isEncrypted = false + return nil + default: + return fmt.Errorf("failed to get TLS connection state: %w", err) + } + } + c.isEncrypted = tlsConnState.HandshakeComplete } return nil } @@ -785,6 +796,34 @@ func (c *Client) auth() error { return ErrXOauth2AuthNotSupported } c.smtpAuth = smtp.XOAuth2Auth(c.user, c.pass) + case SMTPAuthSCRAMSHA1: + if !strings.Contains(smtpAuthType, string(SMTPAuthSCRAMSHA1)) { + return ErrSCRAMSHA1AuthNotSupported + } + c.smtpAuth = smtp.ScramSHA1Auth(c.user, c.pass) + case SMTPAuthSCRAMSHA256: + if !strings.Contains(smtpAuthType, string(SMTPAuthSCRAMSHA256)) { + return ErrSCRAMSHA256AuthNotSupported + } + c.smtpAuth = smtp.ScramSHA256Auth(c.user, c.pass) + case SMTPAuthSCRAMSHA1PLUS: + if !strings.Contains(smtpAuthType, string(SMTPAuthSCRAMSHA1PLUS)) { + return ErrSCRAMSHA1PLUSAuthNotSupported + } + tlsConnState, err := c.smtpClient.GetTLSConnectionState() + if err != nil { + return err + } + c.smtpAuth = smtp.ScramSHA1PlusAuth(c.user, c.pass, tlsConnState) + case SMTPAuthSCRAMSHA256PLUS: + if !strings.Contains(smtpAuthType, string(SMTPAuthSCRAMSHA256PLUS)) { + return ErrSCRAMSHA256PLUSAuthNotSupported + } + tlsConnState, err := c.smtpClient.GetTLSConnectionState() + if err != nil { + return err + } + c.smtpAuth = smtp.ScramSHA256PlusAuth(c.user, c.pass, tlsConnState) default: return fmt.Errorf("unsupported SMTP AUTH type %q", c.smtpAuthType) } diff --git a/client_test.go b/client_test.go index 2d37ce0..481b9be 100644 --- a/client_test.go +++ b/client_test.go @@ -1836,6 +1836,186 @@ func TestClient_DialSendConcurrent_local(t *testing.T) { } } +func TestClient_AuthSCRAMSHAX(t *testing.T) { + if os.Getenv("TEST_ONLINE_SCRAM") == "" { + t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") + } + hostname := os.Getenv("TEST_HOST_SCRAM") + username := os.Getenv("TEST_USER_SCRAM") + password := os.Getenv("TEST_PASS_SCRAM") + + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1}, + {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), + WithSMTPAuth(tt.authtype), + WithUsername(username), WithPassword(password)) + if err != nil { + t.Errorf("unable to create new client: %s", err) + return + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } + }) + } +} + +func TestClient_AuthSCRAMSHAX_fail(t *testing.T) { + if os.Getenv("TEST_ONLINE_SCRAM") == "" { + t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") + } + hostname := os.Getenv("TEST_HOST_SCRAM") + + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1}, + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), + WithSMTPAuth(tt.authtype), + WithUsername("invalid"), WithPassword("invalid")) + if err != nil { + t.Errorf("unable to create new client: %s", err) + return + } + if err = client.DialWithContext(context.Background()); err == nil { + t.Errorf("expected error but got nil") + } + }) + } +} + +func TestClient_AuthSCRAMSHAX_unsupported(t *testing.T) { + if os.Getenv("TEST_ALLOW_SEND") == "" { + t.Skipf("TEST_ALLOW_SEND is not set. Skipping mail sending test") + } + + client, err := getTestConnection(true) + if err != nil { + t.Skipf("failed to create test client: %s. Skipping tests", err) + } + + tests := []struct { + name string + authtype SMTPAuthType + expErr error + }{ + {"SCRAM-SHA-1", SMTPAuthSCRAMSHA1, ErrSCRAMSHA1AuthNotSupported}, + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS, ErrSCRAMSHA1PLUSAuthNotSupported}, + {"SCRAM-SHA-256", SMTPAuthSCRAMSHA256, ErrSCRAMSHA256AuthNotSupported}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS, ErrSCRAMSHA256PLUSAuthNotSupported}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client.SetSMTPAuth(tt.authtype) + client.SetTLSPolicy(TLSMandatory) + if err = client.DialWithContext(context.Background()); err == nil { + t.Errorf("expected error but got nil") + } + if !errors.Is(err, tt.expErr) { + t.Errorf("expected error %s, but got %s", tt.expErr, err) + } + }) + } +} + +func TestClient_AuthSCRAMSHAXPLUS_tlsexporter(t *testing.T) { + if os.Getenv("TEST_ONLINE_SCRAM") == "" { + t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") + } + hostname := os.Getenv("TEST_HOST_SCRAM") + username := os.Getenv("TEST_USER_SCRAM") + password := os.Getenv("TEST_PASS_SCRAM") + + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), + WithSMTPAuth(tt.authtype), + WithUsername(username), WithPassword(password)) + if err != nil { + t.Errorf("unable to create new client: %s", err) + return + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } + }) + } +} + +func TestClient_AuthSCRAMSHAXPLUS_tlsunique(t *testing.T) { + if os.Getenv("TEST_ONLINE_SCRAM") == "" { + t.Skipf("TEST_ONLINE_SCRAM is not set. Skipping online SCRAM tests") + } + hostname := os.Getenv("TEST_HOST_SCRAM") + username := os.Getenv("TEST_USER_SCRAM") + password := os.Getenv("TEST_PASS_SCRAM") + tlsConfig := &tls.Config{} + tlsConfig.MaxVersion = tls.VersionTLS12 + tlsConfig.ServerName = hostname + + tests := []struct { + name string + authtype SMTPAuthType + }{ + {"SCRAM-SHA-1-PLUS", SMTPAuthSCRAMSHA1PLUS}, + {"SCRAM-SHA-256-PLUS", SMTPAuthSCRAMSHA256PLUS}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := NewClient(hostname, + WithTLSPortPolicy(TLSMandatory), + WithTLSConfig(tlsConfig), + WithSMTPAuth(tt.authtype), + WithUsername(username), WithPassword(password)) + if err != nil { + t.Errorf("unable to create new client: %s", err) + return + } + if err = client.DialWithContext(context.Background()); err != nil { + t.Errorf("failed to dial to test server: %s", err) + } + if err = client.Close(); err != nil { + t.Errorf("failed to close server connection: %s", err) + } + }) + } +} + // getTestConnection takes environment variables to establish a connection to a real // SMTP server to test all functionality that requires a connection func getTestConnection(auth bool) (*Client, error) { @@ -1878,10 +2058,10 @@ func getTestConnection(auth bool) (*Client, error) { // We don't want to log authentication data in tests c.SetDebugLog(false) } - if err := c.DialWithContext(context.Background()); err != nil { + if err = c.DialWithContext(context.Background()); err != nil { return c, fmt.Errorf("connection to test server failed: %w", err) } - if err := c.Close(); err != nil { + if err = c.Close(); err != nil { return c, fmt.Errorf("disconnect from test server failed: %w", err) } return c, nil diff --git a/go.mod b/go.mod index b155b9b..1dcef3a 100644 --- a/go.mod +++ b/go.mod @@ -5,3 +5,8 @@ module github.com/wneessen/go-mail go 1.16 + +require ( + golang.org/x/crypto v0.27.0 + golang.org/x/text v0.18.0 +) diff --git a/go.sum b/go.sum index e69de29..78b6dba 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,66 @@ +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +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.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= +golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +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= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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/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= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +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.25.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= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +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.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= +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= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +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.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= +golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +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= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/go.sum.license b/go.sum.license new file mode 100644 index 0000000..615ea2b --- /dev/null +++ b/go.sum.license @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT diff --git a/smtp/auth_scram.go b/smtp/auth_scram.go new file mode 100644 index 0000000..c70b210 --- /dev/null +++ b/smtp/auth_scram.go @@ -0,0 +1,314 @@ +// SPDX-FileCopyrightText: Copyright (c) 2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package smtp + +import ( + "bytes" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "errors" + "fmt" + "hash" + "io" + "strconv" + "strings" + + "golang.org/x/crypto/pbkdf2" + "golang.org/x/text/secure/precis" +) + +// scramAuth represents a SCRAM (Salted Challenge Response Authentication Mechanism) client and +// satisfies the smtp.Auth interface. +type scramAuth struct { + username, password, algorithm string + firstBareMsg, nonce, saltedPwd, authMessage []byte + iterations int + h func() hash.Hash + isPlus bool + tlsConnState *tls.ConnectionState + bindData []byte +} + +// ScramSHA1Auth creates and returns a new SCRAM-SHA-1 authentication mechanism with the given +// username and password. +func ScramSHA1Auth(username, password string) Auth { + return &scramAuth{ + username: username, + password: password, + algorithm: "SCRAM-SHA-1", + h: sha1.New, + } +} + +// ScramSHA256Auth creates and returns a new SCRAM-SHA-256 authentication mechanism with the given +// username and password. +func ScramSHA256Auth(username, password string) Auth { + return &scramAuth{ + username: username, + password: password, + algorithm: "SCRAM-SHA-256", + h: sha256.New, + } +} + +// ScramSHA1PlusAuth returns an Auth instance configured for SCRAM-SHA-1-PLUS authentication with +// the provided username, password, and TLS connection state. +func ScramSHA1PlusAuth(username, password string, tlsConnState *tls.ConnectionState) Auth { + return &scramAuth{ + username: username, + password: password, + algorithm: "SCRAM-SHA-1-PLUS", + h: sha1.New, + isPlus: true, + tlsConnState: tlsConnState, + } +} + +// ScramSHA256PlusAuth returns an Auth instance configured for SCRAM-SHA-256-PLUS authentication with +// the provided username, password, and TLS connection state. +func ScramSHA256PlusAuth(username, password string, tlsConnState *tls.ConnectionState) Auth { + return &scramAuth{ + username: username, + password: password, + algorithm: "SCRAM-SHA-256-PLUS", + h: sha256.New, + isPlus: true, + tlsConnState: tlsConnState, + } +} + +// Start initializes the SCRAM authentication process and returns the selected algorithm, nil data, and no error. +func (a *scramAuth) Start(_ *ServerInfo) (string, []byte, error) { + return a.algorithm, nil, nil +} + +// Next processes the server's challenge and returns the client's response for SCRAM authentication. +func (a *scramAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + if len(fromServer) == 0 { + a.reset() + return a.initialClientMessage() + } + switch { + case bytes.HasPrefix(fromServer, []byte("r=")): + resp, err := a.handleServerFirstResponse(fromServer) + if err != nil { + a.reset() + return nil, err + } + return resp, nil + case bytes.HasPrefix(fromServer, []byte("v=")): + resp, err := a.handleServerValidationMessage(fromServer) + if err != nil { + a.reset() + return nil, err + } + return resp, nil + default: + a.reset() + return nil, errors.New("unexpected server response") + } + } + return nil, nil +} + +// reset clears all authentication-related properties in the scramAuth instance, effectively resetting its state. +func (a *scramAuth) reset() { + a.nonce = nil + a.firstBareMsg = nil + a.saltedPwd = nil + a.authMessage = nil + a.iterations = 0 +} + +// initialClientMessage generates the initial message for SCRAM authentication, including a nonce and +// optional channel binding. +func (a *scramAuth) initialClientMessage() ([]byte, error) { + username, err := a.normalizeUsername() + if err != nil { + return nil, fmt.Errorf("username normalization failed: %w", err) + } + + nonceBuffer := make([]byte, 24) + if _, err := io.ReadFull(rand.Reader, nonceBuffer); err != nil { + return nil, fmt.Errorf("unable to generate client secret: %w", err) + } + a.nonce = make([]byte, base64.StdEncoding.EncodedLen(len(nonceBuffer))) + base64.StdEncoding.Encode(a.nonce, nonceBuffer) + + a.firstBareMsg = []byte("n=" + username + ",r=" + string(a.nonce)) + returnBytes := []byte("n,," + string(a.firstBareMsg)) + + // SCRAM-SHA-X-PLUS auth requires channel binding + if a.isPlus { + bindType := "tls-unique" + connState := a.tlsConnState + bindData := connState.TLSUnique + + // crypto/tl: no tls-unique channel binding value for this tls connection, possibly due to missing + // extended master key support and/or resumed connection + // RFC9266:122 tls-unique not defined for tls 1.3 and later + if bindData == nil || connState.Version >= tls.VersionTLS13 { + bindType = "tls-exporter" + bindData, err = connState.ExportKeyingMaterial("EXPORTER-Channel-Binding", []byte{}, 32) + if err != nil { + return nil, fmt.Errorf("unable to export keying material: %w", err) + } + } + bindData = []byte("p=" + bindType + ",," + string(bindData)) + a.bindData = make([]byte, base64.StdEncoding.EncodedLen(len(bindData))) + base64.StdEncoding.Encode(a.bindData, bindData) + returnBytes = []byte("p=" + bindType + ",," + string(a.firstBareMsg)) + } + + return returnBytes, nil +} + +// handleServerFirstResponse processes the first response from the server in SCRAM authentication. +func (a *scramAuth) handleServerFirstResponse(fromServer []byte) ([]byte, error) { + parts := bytes.Split(fromServer, []byte(",")) + if len(parts) < 3 { + return nil, errors.New("not enough fields in the first server response") + } + if !bytes.HasPrefix(parts[0], []byte("r=")) { + return nil, errors.New("first part of the server response does not start with r=") + } + if !bytes.HasPrefix(parts[1], []byte("s=")) { + return nil, errors.New("second part of the server response does not start with s=") + } + if !bytes.HasPrefix(parts[2], []byte("i=")) { + return nil, errors.New("third part of the server response does not start with i=") + } + + combinedNonce := parts[0][2:] + if len(a.nonce) == 0 || !bytes.HasPrefix(combinedNonce, a.nonce) { + return nil, errors.New("server nonce does not start with our nonce") + } + a.nonce = combinedNonce + + encodedSalt := parts[1][2:] + salt := make([]byte, base64.StdEncoding.DecodedLen(len(encodedSalt))) + n, err := base64.StdEncoding.Decode(salt, encodedSalt) + if err != nil { + return nil, fmt.Errorf("invalid encoded salt: %w", err) + } + salt = salt[:n] + + iterations, err := strconv.Atoi(string(parts[2][2:])) + if err != nil { + return nil, fmt.Errorf("invalid iterations: %w", err) + } + a.iterations = iterations + + password, err := a.normalizeString(a.password) + if err != nil { + return nil, fmt.Errorf("unable to normalize password: %w", err) + } + + a.saltedPwd = pbkdf2.Key([]byte(password), salt, a.iterations, a.h().Size(), a.h) + + msgWithoutProof := []byte("c=biws,r=" + string(a.nonce)) + + // A PLUS authentication requires the channel binding data + if a.isPlus { + msgWithoutProof = []byte("c=" + string(a.bindData) + ",r=" + string(a.nonce)) + } + + a.authMessage = []byte(string(a.firstBareMsg) + "," + string(fromServer) + "," + string(msgWithoutProof)) + clientProof := a.computeClientProof() + + return []byte(string(msgWithoutProof) + ",p=" + string(clientProof)), nil +} + +// handleServerValidationMessage verifies the server's signature during the SCRAM authentication process. +func (a *scramAuth) handleServerValidationMessage(fromServer []byte) ([]byte, error) { + serverSignature := fromServer[2:] + computedServerSignature := a.computeServerSignature() + + if !hmac.Equal(serverSignature, computedServerSignature) { + return nil, errors.New("invalid server signature") + } + return []byte(""), nil +} + +// computeHMAC generates a Hash-based Message Authentication Code (HMAC) using the specified key and message. +func (a *scramAuth) computeHMAC(key, msg []byte) []byte { + mac := hmac.New(a.h, key) + mac.Write(msg) + return mac.Sum(nil) +} + +// computeHash generates a hash of the given key using the configured hashing algorithm. +func (a *scramAuth) computeHash(key []byte) []byte { + hasher := a.h() + hasher.Write(key) + return hasher.Sum(nil) +} + +// computeClientProof generates the client proof as part of the SCRAM authentication process. +func (a *scramAuth) computeClientProof() []byte { + clientKey := a.computeHMAC(a.saltedPwd, []byte("Client Key")) + storedKey := a.computeHash(clientKey) + clientSignature := a.computeHMAC(storedKey[:], a.authMessage) + clientProof := make([]byte, len(clientSignature)) + for i := 0; i < len(clientSignature); i++ { + clientProof[i] = clientKey[i] ^ clientSignature[i] + } + buf := make([]byte, base64.StdEncoding.EncodedLen(len(clientProof))) + base64.StdEncoding.Encode(buf, clientProof) + return buf +} + +// computeServerSignature returns the computed base64-encoded server signature in the SCRAM +// authentication process. +func (a *scramAuth) computeServerSignature() []byte { + serverKey := a.computeHMAC(a.saltedPwd, []byte("Server Key")) + serverSignature := a.computeHMAC(serverKey, a.authMessage) + buf := make([]byte, base64.StdEncoding.EncodedLen(len(serverSignature))) + base64.StdEncoding.Encode(buf, serverSignature) + return buf +} + +// normalizeUsername replaces special characters in the username for SCRAM authentication +// and prepares it using the SASLprep profile as per RFC 8265, returning the normalized +// username or an error. +func (a *scramAuth) normalizeUsername() (string, error) { + // RFC 5802 section 5.1: the characters ',' or '=' in usernames are + // sent as '=2C' and '=3D' respectively. + replacer := strings.NewReplacer("=", "=3D", ",", "=2C") + username := replacer.Replace(a.username) + // RFC 5802 section 5.1: before sending the username to the server, + // the client SHOULD prepare the username using the "SASLprep" + // profile [RFC4013] of the "stringprep" algorithm [RFC3454] + // treating it as a query string (i.e., unassigned Unicode code + // points are allowed). If the preparation of the username fails or + // results in an empty string, the client SHOULD abort the + // authentication exchange. + // + // Since RFC 8265 obsoletes RFC 4013 we use it instead. + username, err := a.normalizeString(username) + if err != nil { + return "", fmt.Errorf("unable to normalize username: %w", err) + } + return username, nil +} + +// normalizeString normalizes the input string according to the OpaqueString profile of the +// precis framework. It returns the normalized string or an error if normalization fails or +// results in an empty string. +func (a *scramAuth) normalizeString(s string) (string, error) { + s, err := precis.OpaqueString.String(s) + if err != nil { + return "", fmt.Errorf("failled to normalize string: %w", err) + } + if s == "" { + return "", errors.New("normalized string is empty") + } + return s, nil +} diff --git a/smtp/smtp.go b/smtp/smtp.go index 4ea1a3d..f9961c9 100644 --- a/smtp/smtp.go +++ b/smtp/smtp.go @@ -36,6 +36,8 @@ import ( "github.com/wneessen/go-mail/log" ) +var ErrNonTLSConnection = errors.New("connection is not using TLS") + // 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. @@ -565,6 +567,25 @@ func (c *Client) UpdateDeadline(timeout time.Duration) error { return nil } +// GetTLSConnectionState retrieves the TLS connection state of the client's current connection. +// Returns an error if the connection is not using TLS or if the connection is not established. +func (c *Client) GetTLSConnectionState() (*tls.ConnectionState, error) { + c.mutex.RLock() + defer c.mutex.RUnlock() + + if !c.tls { + return nil, ErrNonTLSConnection + } + if c.conn == nil { + return nil, errors.New("smtp: connection is not established") + } + if conn, ok := c.conn.(*tls.Conn); ok { + cstate := conn.ConnectionState() + return &cstate, nil + } + return nil, errors.New("smtp: connection is not a TLS connection") +} + // debugLog checks if the debug flag is set and if so logs the provided message to // the log.Logger interface func (c *Client) debugLog(d log.Direction, f string, a ...interface{}) {