From 07d9654ce72daab7e98955b20f1eb82a8f563b17 Mon Sep 17 00:00:00 2001 From: theexiile1305 Date: Sun, 15 Sep 2024 19:49:27 +0200 Subject: [PATCH 1/9] Attribute sMimeSinging was added in msg that indicates whether the message should be singed with S/MIME when it's sent. Also, sMimeAuthConfig was introduced in client so that required privateKey and certificate can be used for S/MIME signing. --- client.go | 17 ++++++++++++ client_test.go | 75 +++++++++++++++++++++++++++++++++++++++++++++++++- msg.go | 10 +++++++ msg_test.go | 8 ++++++ sime.go | 12 ++++++++ 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 sime.go diff --git a/client.go b/client.go index 1d175d1..a2275ad 100644 --- a/client.go +++ b/client.go @@ -127,6 +127,9 @@ type Client struct { // smtpAuthType represents the authentication type for SMTP AUTH smtpAuthType SMTPAuthType + // SMimeAuthConfig represents the authentication type for s/mime crypto key material + sMimeAuthConfig *SMimeAuthConfig + // smtpClient is the smtp.Client that is set up when using the Dial*() methods smtpClient *smtp.Client @@ -168,6 +171,9 @@ var ( // ErrInvalidTLSConfig should be used if an empty tls.Config is provided ErrInvalidTLSConfig = errors.New("invalid TLS config") + // ErrInvalidSMimeAuthConfig should be used if the values in the struct SMimeAuthConfig are empty + ErrInvalidSMimeAuthConfig = errors.New("invalid S/MIME authentication config") + // ErrNoHostname should be used if a Client has no hostname set ErrNoHostname = errors.New("hostname for client cannot be empty") @@ -459,6 +465,17 @@ func WithDialContextFunc(dialCtxFunc DialContextFunc) Option { } } +// WithSMimeConfig tells the client to use the provided SMIMEAuth for s/mime crypto key material authentication +func WithSMimeConfig(sMimeAuthConfig *SMimeAuthConfig) Option { + return func(c *Client) error { + if sMimeAuthConfig.Certificate == nil && sMimeAuthConfig.PrivateKey == nil { + return ErrInvalidSMimeAuthConfig + } + c.sMimeAuthConfig = sMimeAuthConfig + return nil + } +} + // TLSPolicy returns the currently set TLSPolicy as string func (c *Client) TLSPolicy() string { return c.tlspolicy.String() diff --git a/client_test.go b/client_test.go index 0ad309a..cae8d71 100644 --- a/client_test.go +++ b/client_test.go @@ -6,10 +6,15 @@ package mail import ( "context" + "crypto/rand" + "crypto/rsa" "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "errors" "fmt" "io" + "math/big" "net" "os" "strconv" @@ -117,7 +122,7 @@ func TestNewClientWithOptions(t *testing.T) { {"WithDialContextFunc()", WithDialContextFunc(func(ctx context.Context, network, address string) (net.Conn, error) { return nil, nil }), false}, - + {"WithSMimeConfig()", WithSMimeConfig(&SMimeAuthConfig{}), true}, { "WithDSNRcptNotifyType() NEVER combination", WithDSNRcptNotifyType(DSNRcptNotifySuccess, DSNRcptNotifyNever), true, @@ -756,6 +761,43 @@ func TestClient_DialWithContextInvalidAuth(t *testing.T) { } } +// TestWithSMime tests the WithSMime method with invalid SMimeAuthConfig for the Client object +func TestWithSMime_InvalidConfig(t *testing.T) { + _, err := NewClient(DefaultHost, WithSMimeConfig(&SMimeAuthConfig{})) + if !errors.Is(err, ErrInvalidSMimeAuthConfig) { + t.Errorf("failed to check sMimeAuthConfig values correctly: %s", err) + } +} + +// TestWithSMime tests the WithSMime method with valid SMimeAuthConfig that is loaded from dummy certificate for the Client object +func TestWithSMime_ValidConfig(t *testing.T) { + privateKey, err := getDummyPrivateKey() + if err != nil { + t.Errorf("failed to load dummy private key: %s", err) + } + + certificate, err := getDummyCertificate(privateKey) + if err != nil { + t.Errorf("failed to load dummy certificate: %s", err) + } + + sMimeAuthConfig := &SMimeAuthConfig{PrivateKey: privateKey, Certificate: certificate} + c, err := NewClient(DefaultHost, WithSMimeConfig(sMimeAuthConfig)) + if err != nil { + t.Errorf("failed to create new client: %s", err) + } + + if c.sMimeAuthConfig != sMimeAuthConfig { + t.Errorf("failed to set smeAuthConfig. Expected %v, got: %v", sMimeAuthConfig, c.sMimeAuthConfig) + } + if c.sMimeAuthConfig.PrivateKey != sMimeAuthConfig.PrivateKey { + t.Errorf("failed to set smeAuthConfig.PrivateKey Expected %v, got: %v", sMimeAuthConfig, c.sMimeAuthConfig) + } + if c.sMimeAuthConfig.Certificate != sMimeAuthConfig.Certificate { + t.Errorf("failed to set smeAuthConfig.Certificate Expected %v, got: %v", sMimeAuthConfig, c.sMimeAuthConfig) + } +} + // TestClient_checkConn tests the checkConn method with intentional breaking for the Client object func TestClient_checkConn(t *testing.T) { c, err := getTestConnection(true) @@ -1484,6 +1526,37 @@ func getFakeDialFunc(conn net.Conn) DialContextFunc { } } +func getDummyPrivateKey() (*rsa.PrivateKey, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + return privateKey, nil +} + +func getDummyCertificate(privateKey *rsa.PrivateKey) (*x509.Certificate, error) { + template := &x509.Certificate{ + SerialNumber: big.NewInt(1234), + Subject: pkix.Name{Organization: []string{"My Organization"}}, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, err + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + + return cert, nil +} + type faker struct { io.ReadWriter } diff --git a/msg.go b/msg.go index a909d04..17fdd14 100644 --- a/msg.go +++ b/msg.go @@ -120,6 +120,9 @@ type Msg struct { // noDefaultUserAgent indicates whether the default User Agent will be excluded for the Msg when it's sent. noDefaultUserAgent bool + + // sMimeSinging indicates whether the message should be singed with S/MIME when it's sent. + sMimeSinging bool } // SendmailPath is the default system path to the sendmail binary @@ -202,6 +205,13 @@ func WithNoDefaultUserAgent() MsgOption { } } +// WithSMimeSinging configures the Msg to be S/MIME singed sent. +func WithSMimeSinging() MsgOption { + return func(m *Msg) { + m.sMimeSinging = true + } +} + // SetCharset sets the encoding charset of the Msg func (m *Msg) SetCharset(c Charset) { m.charset = c diff --git a/msg_test.go b/msg_test.go index 16cd196..082f79f 100644 --- a/msg_test.go +++ b/msg_test.go @@ -3233,6 +3233,14 @@ func TestNewMsgWithNoDefaultUserAgent(t *testing.T) { } } +// TestWithSMimeSinging tests WithSMimeSinging +func TestWithSMimeSinging(t *testing.T) { + m := NewMsg(WithSMimeSinging()) + if m.sMimeSinging != true { + t.Errorf("WithSMimeSinging() failed. Expected: %t, got: %t", true, false) + } +} + // Fuzzing tests func FuzzMsg_Subject(f *testing.F) { f.Add("Testsubject") diff --git a/sime.go b/sime.go new file mode 100644 index 0000000..714d3a4 --- /dev/null +++ b/sime.go @@ -0,0 +1,12 @@ +package mail + +import ( + "crypto/rsa" + "crypto/x509" +) + +// SMimeAuthConfig represents the authentication type for s/mime crypto key material +type SMimeAuthConfig struct { + Certificate *x509.Certificate + PrivateKey *rsa.PrivateKey +} From 158c1b0458a01266d4f61b4cc3ad81f34f6b94d2 Mon Sep 17 00:00:00 2001 From: theexiile1305 Date: Wed, 18 Sep 2024 14:13:24 +0200 Subject: [PATCH 2/9] moved S/MIME initialization into msg and the pointer to the data structure, also adjusted tests --- client.go | 17 ------------ client_test.go | 74 -------------------------------------------------- msg.go | 20 ++++++++++---- msg_test.go | 32 ++++++++++++++++++---- util_test.go | 41 ++++++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 102 deletions(-) create mode 100644 util_test.go diff --git a/client.go b/client.go index a2275ad..1d175d1 100644 --- a/client.go +++ b/client.go @@ -127,9 +127,6 @@ type Client struct { // smtpAuthType represents the authentication type for SMTP AUTH smtpAuthType SMTPAuthType - // SMimeAuthConfig represents the authentication type for s/mime crypto key material - sMimeAuthConfig *SMimeAuthConfig - // smtpClient is the smtp.Client that is set up when using the Dial*() methods smtpClient *smtp.Client @@ -171,9 +168,6 @@ var ( // ErrInvalidTLSConfig should be used if an empty tls.Config is provided ErrInvalidTLSConfig = errors.New("invalid TLS config") - // ErrInvalidSMimeAuthConfig should be used if the values in the struct SMimeAuthConfig are empty - ErrInvalidSMimeAuthConfig = errors.New("invalid S/MIME authentication config") - // ErrNoHostname should be used if a Client has no hostname set ErrNoHostname = errors.New("hostname for client cannot be empty") @@ -465,17 +459,6 @@ func WithDialContextFunc(dialCtxFunc DialContextFunc) Option { } } -// WithSMimeConfig tells the client to use the provided SMIMEAuth for s/mime crypto key material authentication -func WithSMimeConfig(sMimeAuthConfig *SMimeAuthConfig) Option { - return func(c *Client) error { - if sMimeAuthConfig.Certificate == nil && sMimeAuthConfig.PrivateKey == nil { - return ErrInvalidSMimeAuthConfig - } - c.sMimeAuthConfig = sMimeAuthConfig - return nil - } -} - // TLSPolicy returns the currently set TLSPolicy as string func (c *Client) TLSPolicy() string { return c.tlspolicy.String() diff --git a/client_test.go b/client_test.go index cae8d71..afe384b 100644 --- a/client_test.go +++ b/client_test.go @@ -6,15 +6,10 @@ package mail import ( "context" - "crypto/rand" - "crypto/rsa" "crypto/tls" - "crypto/x509" - "crypto/x509/pkix" "errors" "fmt" "io" - "math/big" "net" "os" "strconv" @@ -122,7 +117,6 @@ func TestNewClientWithOptions(t *testing.T) { {"WithDialContextFunc()", WithDialContextFunc(func(ctx context.Context, network, address string) (net.Conn, error) { return nil, nil }), false}, - {"WithSMimeConfig()", WithSMimeConfig(&SMimeAuthConfig{}), true}, { "WithDSNRcptNotifyType() NEVER combination", WithDSNRcptNotifyType(DSNRcptNotifySuccess, DSNRcptNotifyNever), true, @@ -761,43 +755,6 @@ func TestClient_DialWithContextInvalidAuth(t *testing.T) { } } -// TestWithSMime tests the WithSMime method with invalid SMimeAuthConfig for the Client object -func TestWithSMime_InvalidConfig(t *testing.T) { - _, err := NewClient(DefaultHost, WithSMimeConfig(&SMimeAuthConfig{})) - if !errors.Is(err, ErrInvalidSMimeAuthConfig) { - t.Errorf("failed to check sMimeAuthConfig values correctly: %s", err) - } -} - -// TestWithSMime tests the WithSMime method with valid SMimeAuthConfig that is loaded from dummy certificate for the Client object -func TestWithSMime_ValidConfig(t *testing.T) { - privateKey, err := getDummyPrivateKey() - if err != nil { - t.Errorf("failed to load dummy private key: %s", err) - } - - certificate, err := getDummyCertificate(privateKey) - if err != nil { - t.Errorf("failed to load dummy certificate: %s", err) - } - - sMimeAuthConfig := &SMimeAuthConfig{PrivateKey: privateKey, Certificate: certificate} - c, err := NewClient(DefaultHost, WithSMimeConfig(sMimeAuthConfig)) - if err != nil { - t.Errorf("failed to create new client: %s", err) - } - - if c.sMimeAuthConfig != sMimeAuthConfig { - t.Errorf("failed to set smeAuthConfig. Expected %v, got: %v", sMimeAuthConfig, c.sMimeAuthConfig) - } - if c.sMimeAuthConfig.PrivateKey != sMimeAuthConfig.PrivateKey { - t.Errorf("failed to set smeAuthConfig.PrivateKey Expected %v, got: %v", sMimeAuthConfig, c.sMimeAuthConfig) - } - if c.sMimeAuthConfig.Certificate != sMimeAuthConfig.Certificate { - t.Errorf("failed to set smeAuthConfig.Certificate Expected %v, got: %v", sMimeAuthConfig, c.sMimeAuthConfig) - } -} - // TestClient_checkConn tests the checkConn method with intentional breaking for the Client object func TestClient_checkConn(t *testing.T) { c, err := getTestConnection(true) @@ -1526,37 +1483,6 @@ func getFakeDialFunc(conn net.Conn) DialContextFunc { } } -func getDummyPrivateKey() (*rsa.PrivateKey, error) { - privateKey, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return nil, err - } - return privateKey, nil -} - -func getDummyCertificate(privateKey *rsa.PrivateKey) (*x509.Certificate, error) { - template := &x509.Certificate{ - SerialNumber: big.NewInt(1234), - Subject: pkix.Name{Organization: []string{"My Organization"}}, - NotBefore: time.Now(), - NotAfter: time.Now().AddDate(1, 0, 0), - KeyUsage: x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - } - - certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) - if err != nil { - return nil, err - } - - cert, err := x509.ParseCertificate(certDER) - if err != nil { - return nil, err - } - - return cert, nil -} - type faker struct { io.ReadWriter } diff --git a/msg.go b/msg.go index 17fdd14..24e2ca6 100644 --- a/msg.go +++ b/msg.go @@ -121,8 +121,8 @@ type Msg struct { // noDefaultUserAgent indicates whether the default User Agent will be excluded for the Msg when it's sent. noDefaultUserAgent bool - // sMimeSinging indicates whether the message should be singed with S/MIME when it's sent. - sMimeSinging bool + // SMime represents a middleware used to sign messages with S/MIME + sMime *SMime } // SendmailPath is the default system path to the sendmail binary @@ -205,11 +205,14 @@ func WithNoDefaultUserAgent() MsgOption { } } -// WithSMimeSinging configures the Msg to be S/MIME singed sent. -func WithSMimeSinging() MsgOption { - return func(m *Msg) { - m.sMimeSinging = true +// SignWithSMime configures the Msg to be signed with S/MIME +func (m *Msg) SignWithSMime(privateKey *rsa.PrivateKey, certificate *x509.Certificate) error { + sMime, err := NewSMime(privateKey, certificate) + if err != nil { + return err } + m.sMime = sMime + return nil } // SetCharset sets the encoding charset of the Msg @@ -1176,6 +1179,11 @@ func (m *Msg) hasMixed() bool { return m.pgptype == 0 && ((len(m.parts) > 0 && len(m.attachments) > 0) || len(m.attachments) > 1) } +// hasSMime returns true if the Msg has should be signed with S/MIME +func (m *Msg) hasSMime() bool { + return m.sMime != nil +} + // hasRelated returns true if the Msg has related parts func (m *Msg) hasRelated() bool { return m.pgptype == 0 && ((len(m.parts) > 0 && len(m.embeds) > 0) || len(m.embeds) > 1) diff --git a/msg_test.go b/msg_test.go index 082f79f..e133397 100644 --- a/msg_test.go +++ b/msg_test.go @@ -3233,11 +3233,33 @@ func TestNewMsgWithNoDefaultUserAgent(t *testing.T) { } } -// TestWithSMimeSinging tests WithSMimeSinging -func TestWithSMimeSinging(t *testing.T) { - m := NewMsg(WithSMimeSinging()) - if m.sMimeSinging != true { - t.Errorf("WithSMimeSinging() failed. Expected: %t, got: %t", true, false) +// TestWithSMimeSinging_ValidPrivateKey tests WithSMimeSinging with given privateKey +func TestWithSMimeSinging_ValidPrivateKey(t *testing.T) { + privateKey, err := getDummyPrivateKey() + if err != nil { + t.Errorf("failed to load dummy private key: %s", err) + } + certificate, err := getDummyCertificate(privateKey) + if err != nil { + t.Errorf("failed to load dummy certificate: %s", err) + } + + m := NewMsg() + if err := m.SignWithSMime(privateKey, certificate); err != nil { + t.Errorf("failed to set sMime. Cause: %v", err) + } + if m.sMime.privateKey != privateKey { + t.Errorf("WithSMimeSinging. Expected %v, got: %v", privateKey, m.sMime.privateKey) + } +} + +// TestWithSMimeSinging_InvalidPrivateKey tests WithSMimeSinging with given invalid privateKey +func TestWithSMimeSinging_InvalidPrivateKey(t *testing.T) { + m := NewMsg() + + err := m.SignWithSMime(nil, nil) + if !errors.Is(err, ErrInvalidPrivateKey) { + t.Errorf("failed to check sMimeAuthConfig values correctly: %s", err) } } diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..efc088c --- /dev/null +++ b/util_test.go @@ -0,0 +1,41 @@ +package mail + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "time" +) + +func getDummyPrivateKey() (*rsa.PrivateKey, error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + return privateKey, nil +} + +func getDummyCertificate(privateKey *rsa.PrivateKey) (*x509.Certificate, error) { + template := &x509.Certificate{ + SerialNumber: big.NewInt(1234), + Subject: pkix.Name{Organization: []string{"My Organization"}}, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(1, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) + if err != nil { + return nil, err + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + + return cert, nil +} From 6fda661dc78fe605791b5ffa39263ec4f6a2df7e Mon Sep 17 00:00:00 2001 From: theexiile1305 Date: Wed, 18 Sep 2024 14:16:48 +0200 Subject: [PATCH 3/9] added content type, mime type and content disposition for S/MIME singing purpose --- encoding.go | 15 +++++++++++++++ encoding_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/encoding.go b/encoding.go index 47213da..0640d31 100644 --- a/encoding.go +++ b/encoding.go @@ -19,6 +19,9 @@ type MIMEVersion string // MIMEType represents the MIME type for the mail type MIMEType string +// Disposition represents a content disposition for the Msg +type Disposition string + // List of supported encodings const ( // EncodingB64 represents the Base64 encoding as specified in RFC 2045. @@ -149,6 +152,7 @@ const ( TypePGPEncrypted ContentType = "application/pgp-encrypted" TypeTextHTML ContentType = "text/html" TypeTextPlain ContentType = "text/plain" + typeSMimeSigned ContentType = `application/pkcs7-signature; name="smime.p7s"` ) // List of MIMETypes @@ -156,6 +160,12 @@ const ( MIMEAlternative MIMEType = "alternative" MIMEMixed MIMEType = "mixed" MIMERelated MIMEType = "related" + MIMESMime MIMEType = `signed; protocol="application/pkcs7-signature"; micalg=sha256` +) + +// List of common content disposition +const ( + DispositionSMime Disposition = `attachment; filename="smime.p7s"` ) // String is a standard method to convert an Charset into a printable format @@ -172,3 +182,8 @@ func (c ContentType) String() string { func (e Encoding) String() string { return string(e) } + +// String is a standard method to convert an Disposition into a printable format +func (d Disposition) String() string { + return string(d) +} diff --git a/encoding_test.go b/encoding_test.go index 14711b7..3084063 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -61,6 +61,11 @@ func TestContentType_String(t *testing.T) { "ContentType: application/pgp-encrypted", TypePGPEncrypted, "application/pgp-encrypted", }, + + { + "ContentType: pkcs7-signature", typeSMimeSigned, + `application/pkcs7-signature; name="smime.p7s"; smime-type=signed-data`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -121,3 +126,22 @@ func TestCharset_String(t *testing.T) { }) } } + +// TestDisposition_String tests the string method of the Disposition object +func TestDisposition_String(t *testing.T) { + tests := []struct { + name string + d Disposition + want string + }{ + {"Disposition: S/Mime", DispositionSMime, `attachment; filename="smime.p7s"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.d.String() != tt.want { + t.Errorf("wrong string for Disposition returned. Expected: %s, got: %s", + tt.want, tt.d.String()) + } + }) + } +} From e0a59dba6da226dfae2e8fdded66c1e538afb083 Mon Sep 17 00:00:00 2001 From: theexiile1305 Date: Wed, 18 Sep 2024 14:17:40 +0200 Subject: [PATCH 4/9] introduced content disposition in part --- part.go | 18 ++++++++++++++++++ part_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/part.go b/part.go index 7c76b7d..005da29 100644 --- a/part.go +++ b/part.go @@ -17,6 +17,7 @@ type Part struct { contentType ContentType charset Charset description string + disposition Disposition encoding Encoding isDeleted bool writeFunc func(io.Writer) (int64, error) @@ -56,6 +57,11 @@ func (p *Part) GetDescription() string { return p.description } +// GetDisposition returns the currently set Content-Disposition of the Part +func (p *Part) GetDisposition() Disposition { + return p.disposition +} + // SetContent overrides the content of the Part with the given string func (p *Part) SetContent(content string) { buffer := bytes.NewBufferString(content) @@ -82,6 +88,11 @@ func (p *Part) SetDescription(description string) { p.description = description } +// SetDisposition overrides the Content-Disposition of the Part +func (p *Part) SetDisposition(disposition Disposition) { + p.disposition = disposition +} + // SetWriteFunc overrides the WriteFunc of the Part func (p *Part) SetWriteFunc(writeFunc func(io.Writer) (int64, error)) { p.writeFunc = writeFunc @@ -113,3 +124,10 @@ func WithPartContentDescription(description string) PartOption { p.description = description } } + +// WithContentDisposition overrides the default Part Content-Disposition +func WithContentDisposition(disposition Disposition) PartOption { + return func(p *Part) { + p.disposition = disposition + } +} diff --git a/part_test.go b/part_test.go index a196986..800acff 100644 --- a/part_test.go +++ b/part_test.go @@ -102,6 +102,35 @@ func TestPart_WithPartContentDescription(t *testing.T) { } } +// TestPart_withContentDisposition tests the WithContentDisposition method +func TestPart_withContentDisposition(t *testing.T) { + tests := []struct { + name string + disposition Disposition + }{ + {"Part disposition: test", "test"}, + {"Part disposition: empty", ""}, + } + for _, tt := range tests { + m := NewMsg() + t.Run(tt.name, func(t *testing.T) { + part := m.newPart(TypeTextPlain, WithContentDisposition(tt.disposition), nil) + if part == nil { + t.Errorf("newPart() WithPartContentDescription() failed: no part returned") + return + } + if part.disposition != tt.disposition { + t.Errorf("newPart() WithContentDisposition() failed: expected: %s, got: %s", tt.disposition, part.description) + } + part.disposition = "" + part.SetDisposition(tt.disposition) + if part.disposition != tt.disposition { + t.Errorf("newPart() SetDisposition() failed: expected: %s, got: %s", tt.disposition, part.description) + } + }) + } +} + // TestPartContentType tests Part.SetContentType func TestPart_SetContentType(t *testing.T) { tests := []struct { @@ -323,6 +352,31 @@ func TestPart_SetDescription(t *testing.T) { } } +// TestPart_SetDisposition tests Part.SetDisposition +func TestPart_SetDisposition(t *testing.T) { + c := "This is a test" + d := Disposition("test-disposition") + m := NewMsg() + m.SetBodyString(TypeTextPlain, c) + pl, err := getPartList(m) + if err != nil { + t.Errorf("failed: %s", err) + return + } + pd := pl[0].GetDisposition() + if pd != "" { + t.Errorf("Part.GetDisposition failed. Expected empty description but got: %s", pd) + } + pl[0].SetDisposition(d) + if pl[0].disposition != d { + t.Errorf("Part.SetDisposition failed. Expected description to be: %s, got: %s", d, pd) + } + pd = pl[0].GetDisposition() + if pd != d { + t.Errorf("Part.GetDisposition failed. Expected: %s, got: %s", d, pd) + } +} + // TestPart_Delete tests Part.Delete func TestPart_Delete(t *testing.T) { c := "This is a test with ümläutß" From 94942ed383654a689457b878c4df9609936d579c Mon Sep 17 00:00:00 2001 From: theexiile1305 Date: Wed, 18 Sep 2024 14:23:26 +0200 Subject: [PATCH 5/9] implementation of S/MIME singing without tests of type smime --- go.mod | 2 ++ go.sum | 2 ++ msg.go | 41 +++++++++++++++++++++++++++++++- msgwriter.go | 9 ++++++- sime.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++++---- 5 files changed, 114 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index b155b9b..a59f78a 100644 --- a/go.mod +++ b/go.mod @@ -5,3 +5,5 @@ module github.com/wneessen/go-mail go 1.16 + +require go.mozilla.org/pkcs7 v0.9.0 diff --git a/go.sum b/go.sum index e69de29..4ca3691 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,2 @@ +go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= +go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= diff --git a/msg.go b/msg.go index 24e2ca6..9686dac 100644 --- a/msg.go +++ b/msg.go @@ -7,6 +7,8 @@ package mail import ( "bytes" "context" + "crypto/rsa" + "crypto/x509" "embed" "errors" "fmt" @@ -981,10 +983,47 @@ func (m *Msg) applyMiddlewares(msg *Msg) *Msg { return msg } +// signMessage sign the Msg with S/MIME +func (m *Msg) signMessage(msg *Msg) (*Msg, error) { + currentPart := m.GetParts()[0] + currentPart.SetEncoding(EncodingUSASCII) + currentPart.SetContentType(TypeTextPlain) + content, err := currentPart.GetContent() + if err != nil { + return nil, errors.New("failed to extract content from part") + } + + signedContent, err := m.sMime.Sign(content) + if err != nil { + return nil, errors.New("failed to sign message") + } + + signedPart := msg.newPart( + typeSMimeSigned, + WithPartEncoding(EncodingB64), + WithContentDisposition(DispositionSMime), + ) + signedPart.SetContent(*signedContent) + msg.parts = append(msg.parts, signedPart) + + return msg, nil +} + // WriteTo writes the formated Msg into a give io.Writer and satisfies the io.WriteTo interface func (m *Msg) WriteTo(writer io.Writer) (int64, error) { mw := &msgWriter{writer: writer, charset: m.charset, encoder: m.encoder} - mw.writeMsg(m.applyMiddlewares(m)) + msg := m.applyMiddlewares(m) + + if m.sMime != nil { + signedMsg, err := m.signMessage(msg) + if err != nil { + return 0, err + } + msg = signedMsg + } + + mw.writeMsg(msg) + return mw.bytesWritten, mw.err } diff --git a/msgwriter.go b/msgwriter.go index ff1b47b..684c223 100644 --- a/msgwriter.go +++ b/msgwriter.go @@ -88,6 +88,10 @@ func (mw *msgWriter) writeMsg(msg *Msg) { } } + if msg.hasSMime() { + mw.startMP(MIMESMime, msg.boundary) + mw.writeString(DoubleNewLine) + } if msg.hasMixed() { mw.startMP(MIMEMixed, msg.boundary) mw.writeString(DoubleNewLine) @@ -96,7 +100,7 @@ func (mw *msgWriter) writeMsg(msg *Msg) { mw.startMP(MIMERelated, msg.boundary) mw.writeString(DoubleNewLine) } - if msg.hasAlt() { + if msg.hasAlt() && !msg.hasSMime() { mw.startMP(MIMEAlternative, msg.boundary) mw.writeString(DoubleNewLine) } @@ -265,6 +269,9 @@ func (mw *msgWriter) writePart(part *Part, charset Charset) { if part.description != "" { mimeHeader.Add(string(HeaderContentDescription), part.description) } + if part.disposition != "" { + mimeHeader.Add(string(HeaderContentDisposition), part.disposition.String()) + } mimeHeader.Add(string(HeaderContentType), contentType) mimeHeader.Add(string(HeaderContentTransferEnc), contentTransferEnc) mw.newPart(mimeHeader) diff --git a/sime.go b/sime.go index 714d3a4..f230a69 100644 --- a/sime.go +++ b/sime.go @@ -3,10 +3,68 @@ package mail import ( "crypto/rsa" "crypto/x509" + "errors" + "go.mozilla.org/pkcs7" ) -// SMimeAuthConfig represents the authentication type for s/mime crypto key material -type SMimeAuthConfig struct { - Certificate *x509.Certificate - PrivateKey *rsa.PrivateKey +var ( + // ErrInvalidPrivateKey should be used if private key is invalid + ErrInvalidPrivateKey = errors.New("invalid private key") + + // ErrInvalidCertificate should be used if certificate is invalid + ErrInvalidCertificate = errors.New("invalid certificate") + + // ErrCouldNotInitialize should be used if the signed data could not initialize + ErrCouldNotInitialize = errors.New("could not initialize signed data") + + // ErrCouldNotAddSigner should be used if the signer could not be added + ErrCouldNotAddSigner = errors.New("could not add signer message") + + // ErrCouldNotFinishSigning should be used if the signing could not be finished + ErrCouldNotFinishSigning = errors.New("could not finish signing") +) + +// SMime is used to sign messages with S/MIME +type SMime struct { + privateKey *rsa.PrivateKey + certificate *x509.Certificate +} + +// NewSMime construct a new instance of SMime with a provided *rsa.PrivateKey +func NewSMime(privateKey *rsa.PrivateKey, certificate *x509.Certificate) (*SMime, error) { + if privateKey == nil { + return nil, ErrInvalidPrivateKey + } + + if certificate == nil { + return nil, ErrInvalidCertificate + } + + return &SMime{ + privateKey: privateKey, + certificate: certificate, + }, nil +} + +// Sign the content with the given privateKey of the method NewSMime +func (sm *SMime) Sign(content []byte) (*string, error) { + toBeSigned, err := pkcs7.NewSignedData(content) + + toBeSigned.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256) + if err != nil { + return nil, ErrCouldNotInitialize + } + + if err = toBeSigned.AddSigner(sm.certificate, sm.privateKey, pkcs7.SignerInfoConfig{}); err != nil { + return nil, ErrCouldNotAddSigner + } + + signed, err := toBeSigned.Finish() + if err != nil { + return nil, ErrCouldNotFinishSigning + } + + signedData := string(signed) + + return &signedData, nil } From 41a81966d8f7eabb7042eede0197fc2737ecf060 Mon Sep 17 00:00:00 2001 From: theexiile1305 Date: Wed, 18 Sep 2024 14:28:55 +0200 Subject: [PATCH 6/9] remove new line --- client_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/client_test.go b/client_test.go index afe384b..0ad309a 100644 --- a/client_test.go +++ b/client_test.go @@ -117,6 +117,7 @@ func TestNewClientWithOptions(t *testing.T) { {"WithDialContextFunc()", WithDialContextFunc(func(ctx context.Context, network, address string) (net.Conn, error) { return nil, nil }), false}, + { "WithDSNRcptNotifyType() NEVER combination", WithDSNRcptNotifyType(DSNRcptNotifySuccess, DSNRcptNotifyNever), true, From 894936092edc287fd9d7f4ce803360894ce8aab2 Mon Sep 17 00:00:00 2001 From: theexiile1305 Date: Wed, 18 Sep 2024 14:35:40 +0200 Subject: [PATCH 7/9] fixing test --- encoding_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/encoding_test.go b/encoding_test.go index 3084063..588cff1 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -64,7 +64,7 @@ func TestContentType_String(t *testing.T) { { "ContentType: pkcs7-signature", typeSMimeSigned, - `application/pkcs7-signature; name="smime.p7s"; smime-type=signed-data`, + `application/pkcs7-signature; name="smime.p7s"`, }, } for _, tt := range tests { From 65b9fd07dab98c253291f4984da3ddbc2b66cf39 Mon Sep 17 00:00:00 2001 From: theexiile1305 Date: Thu, 26 Sep 2024 11:07:14 +0200 Subject: [PATCH 8/9] feat: remove unused content disposition --- encoding.go | 13 ------------ encoding_test.go | 19 ----------------- msgwriter.go | 3 --- part.go | 18 ---------------- part_test.go | 54 ------------------------------------------------ 5 files changed, 107 deletions(-) diff --git a/encoding.go b/encoding.go index 0640d31..77243fc 100644 --- a/encoding.go +++ b/encoding.go @@ -19,9 +19,6 @@ type MIMEVersion string // MIMEType represents the MIME type for the mail type MIMEType string -// Disposition represents a content disposition for the Msg -type Disposition string - // List of supported encodings const ( // EncodingB64 represents the Base64 encoding as specified in RFC 2045. @@ -163,11 +160,6 @@ const ( MIMESMime MIMEType = `signed; protocol="application/pkcs7-signature"; micalg=sha256` ) -// List of common content disposition -const ( - DispositionSMime Disposition = `attachment; filename="smime.p7s"` -) - // String is a standard method to convert an Charset into a printable format func (c Charset) String() string { return string(c) @@ -182,8 +174,3 @@ func (c ContentType) String() string { func (e Encoding) String() string { return string(e) } - -// String is a standard method to convert an Disposition into a printable format -func (d Disposition) String() string { - return string(d) -} diff --git a/encoding_test.go b/encoding_test.go index 588cff1..11bf991 100644 --- a/encoding_test.go +++ b/encoding_test.go @@ -126,22 +126,3 @@ func TestCharset_String(t *testing.T) { }) } } - -// TestDisposition_String tests the string method of the Disposition object -func TestDisposition_String(t *testing.T) { - tests := []struct { - name string - d Disposition - want string - }{ - {"Disposition: S/Mime", DispositionSMime, `attachment; filename="smime.p7s"`}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.d.String() != tt.want { - t.Errorf("wrong string for Disposition returned. Expected: %s, got: %s", - tt.want, tt.d.String()) - } - }) - } -} diff --git a/msgwriter.go b/msgwriter.go index 684c223..372e1e1 100644 --- a/msgwriter.go +++ b/msgwriter.go @@ -269,9 +269,6 @@ func (mw *msgWriter) writePart(part *Part, charset Charset) { if part.description != "" { mimeHeader.Add(string(HeaderContentDescription), part.description) } - if part.disposition != "" { - mimeHeader.Add(string(HeaderContentDisposition), part.disposition.String()) - } mimeHeader.Add(string(HeaderContentType), contentType) mimeHeader.Add(string(HeaderContentTransferEnc), contentTransferEnc) mw.newPart(mimeHeader) diff --git a/part.go b/part.go index 005da29..7c76b7d 100644 --- a/part.go +++ b/part.go @@ -17,7 +17,6 @@ type Part struct { contentType ContentType charset Charset description string - disposition Disposition encoding Encoding isDeleted bool writeFunc func(io.Writer) (int64, error) @@ -57,11 +56,6 @@ func (p *Part) GetDescription() string { return p.description } -// GetDisposition returns the currently set Content-Disposition of the Part -func (p *Part) GetDisposition() Disposition { - return p.disposition -} - // SetContent overrides the content of the Part with the given string func (p *Part) SetContent(content string) { buffer := bytes.NewBufferString(content) @@ -88,11 +82,6 @@ func (p *Part) SetDescription(description string) { p.description = description } -// SetDisposition overrides the Content-Disposition of the Part -func (p *Part) SetDisposition(disposition Disposition) { - p.disposition = disposition -} - // SetWriteFunc overrides the WriteFunc of the Part func (p *Part) SetWriteFunc(writeFunc func(io.Writer) (int64, error)) { p.writeFunc = writeFunc @@ -124,10 +113,3 @@ func WithPartContentDescription(description string) PartOption { p.description = description } } - -// WithContentDisposition overrides the default Part Content-Disposition -func WithContentDisposition(disposition Disposition) PartOption { - return func(p *Part) { - p.disposition = disposition - } -} diff --git a/part_test.go b/part_test.go index 800acff..a196986 100644 --- a/part_test.go +++ b/part_test.go @@ -102,35 +102,6 @@ func TestPart_WithPartContentDescription(t *testing.T) { } } -// TestPart_withContentDisposition tests the WithContentDisposition method -func TestPart_withContentDisposition(t *testing.T) { - tests := []struct { - name string - disposition Disposition - }{ - {"Part disposition: test", "test"}, - {"Part disposition: empty", ""}, - } - for _, tt := range tests { - m := NewMsg() - t.Run(tt.name, func(t *testing.T) { - part := m.newPart(TypeTextPlain, WithContentDisposition(tt.disposition), nil) - if part == nil { - t.Errorf("newPart() WithPartContentDescription() failed: no part returned") - return - } - if part.disposition != tt.disposition { - t.Errorf("newPart() WithContentDisposition() failed: expected: %s, got: %s", tt.disposition, part.description) - } - part.disposition = "" - part.SetDisposition(tt.disposition) - if part.disposition != tt.disposition { - t.Errorf("newPart() SetDisposition() failed: expected: %s, got: %s", tt.disposition, part.description) - } - }) - } -} - // TestPartContentType tests Part.SetContentType func TestPart_SetContentType(t *testing.T) { tests := []struct { @@ -352,31 +323,6 @@ func TestPart_SetDescription(t *testing.T) { } } -// TestPart_SetDisposition tests Part.SetDisposition -func TestPart_SetDisposition(t *testing.T) { - c := "This is a test" - d := Disposition("test-disposition") - m := NewMsg() - m.SetBodyString(TypeTextPlain, c) - pl, err := getPartList(m) - if err != nil { - t.Errorf("failed: %s", err) - return - } - pd := pl[0].GetDisposition() - if pd != "" { - t.Errorf("Part.GetDisposition failed. Expected empty description but got: %s", pd) - } - pl[0].SetDisposition(d) - if pl[0].disposition != d { - t.Errorf("Part.SetDisposition failed. Expected description to be: %s, got: %s", d, pd) - } - pd = pl[0].GetDisposition() - if pd != d { - t.Errorf("Part.GetDisposition failed. Expected: %s, got: %s", d, pd) - } -} - // TestPart_Delete tests Part.Delete func TestPart_Delete(t *testing.T) { c := "This is a test with ümläutß" From 3154649420179555644057f3c1927f65b88bd917 Mon Sep 17 00:00:00 2001 From: theexiile1305 Date: Thu, 26 Sep 2024 11:37:53 +0200 Subject: [PATCH 9/9] feat: add flag smime for indicating that the part should be marked for singing with S/MIME --- part.go | 18 ++++++++++++++++++ part_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/part.go b/part.go index 7c76b7d..035b342 100644 --- a/part.go +++ b/part.go @@ -20,6 +20,7 @@ type Part struct { encoding Encoding isDeleted bool writeFunc func(io.Writer) (int64, error) + smime bool } // GetContent executes the WriteFunc of the Part and returns the content as byte slice @@ -56,6 +57,11 @@ func (p *Part) GetDescription() string { return p.description } +// IsSMimeSigned returns true if the Part should be singed with S/MIME +func (p *Part) IsSMimeSigned() bool { + return p.smime +} + // SetContent overrides the content of the Part with the given string func (p *Part) SetContent(content string) { buffer := bytes.NewBufferString(content) @@ -82,6 +88,11 @@ func (p *Part) SetDescription(description string) { p.description = description } +// SetIsSMimeSigned sets the flag for signing the Part with S/MIME +func (p *Part) SetIsSMimeSigned(smime bool) { + p.smime = smime +} + // SetWriteFunc overrides the WriteFunc of the Part func (p *Part) SetWriteFunc(writeFunc func(io.Writer) (int64, error)) { p.writeFunc = writeFunc @@ -113,3 +124,10 @@ func WithPartContentDescription(description string) PartOption { p.description = description } } + +// WithSMimeSinging overrides the flag for signing the Part with S/MIME +func WithSMimeSinging() PartOption { + return func(p *Part) { + p.smime = true + } +} diff --git a/part_test.go b/part_test.go index a196986..2b9725f 100644 --- a/part_test.go +++ b/part_test.go @@ -102,6 +102,24 @@ func TestPart_WithPartContentDescription(t *testing.T) { } } +// TestPart_WithSMimeSinging tests the WithSMimeSinging method +func TestPart_WithSMimeSinging(t *testing.T) { + m := NewMsg() + part := m.newPart(TypeTextPlain, WithSMimeSinging()) + if part == nil { + t.Errorf("newPart() WithSMimeSinging() failed: no part returned") + return + } + if part.smime != true { + t.Errorf("newPart() WithSMimeSinging() failed: expected: %v, got: %v", true, part.smime) + } + part.smime = true + part.SetIsSMimeSigned(false) + if part.smime != false { + t.Errorf("newPart() SetIsSMimeSigned() failed: expected: %v, got: %v", false, part.smime) + } +} + // TestPartContentType tests Part.SetContentType func TestPart_SetContentType(t *testing.T) { tests := []struct { @@ -245,6 +263,32 @@ func TestPart_GetContentBroken(t *testing.T) { } } +// TestPart_IsSMimeSigned tests Part.IsSMimeSigned +func TestPart_IsSMimeSigned(t *testing.T) { + tests := []struct { + name string + want bool + }{ + {"smime:", true}, + {"smime:", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := NewMsg() + pl, err := getPartList(m) + if err != nil { + t.Errorf("failed: %s", err) + return + } + pl[0].SetIsSMimeSigned(tt.want) + smime := pl[0].IsSMimeSigned() + if smime != tt.want { + t.Errorf("SetContentType failed. Got: %v, expected: %v", smime, tt.want) + } + }) + } +} + // TestPart_SetWriteFunc tests Part.SetWriteFunc func TestPart_SetWriteFunc(t *testing.T) { c := "This is a test with ümläutß"