diff --git a/README.md b/README.md index 3a67d0d..9ab4303 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ Here are some highlights of go-mail's featureset: * [X] Custom error types for delivery errors * [X] Custom dial-context functions for more control over the connection (proxing, DNS hooking, etc.) * [X] Output a go-mail message as EML file and parse EML file into a go-mail message +* [X] S/MIME singed messages go-mail works like a programatic email client and provides lots of methods and functionalities you would consider standard in a MUA. diff --git a/dummy-chain-cert.pem b/dummy-chain-cert.pem new file mode 100644 index 0000000..4daceaa --- /dev/null +++ b/dummy-chain-cert.pem @@ -0,0 +1,62 @@ +-----BEGIN CERTIFICATE----- +MIIFWjCCA0KgAwIBAgIUAi7P4JOR4g8b5DMERUtZQEtw+igwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDA5MjYxNDU0MDlaFw0yNTA5 +MjYxNDU0MDlaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQCiLh0JJTRRBhmUyiMKALHtTOK7T20Bwy+fG0SO6RlB +c+hSuuX/n6znXcNgOBlQ2Gg3+p1on/bmcKnGN/SCiVBLpROiwxg3blQbZ7B7Jors +/MopGk0LIBOXHPtAuYbF4J6ND5Ol6sgeGjMnomwRjZlfeuBlHY345MqvcwH/lPhO +lKme+tWD+bsFh08NGS+3NdQGP6dA2bRVrPXhLXStHEmqfKO9EMVLWv+77tYhZESD +6XgnA8pWjbdr9jajCsrQWrCG3jqHtzHNxtwf7xfRwwgoUhLEvue6SBVokZGVmDhv +WdRt2sjtLcWWJCI3p7M+NXRt5qf6iu24wLBdzIDuWfgooWu5vBzNZjSTh2if6R1a +s9BdwASwy1n2HMvqpzgA+f/rXDFbvVc7WIKiuGzfWApBrL0qTCQBuSyepH0G4rQ2 +sJtI5U8QOKBO76nQJq5WDQgefBX4GDI8aJ6qX+teQq1AqERUmLWx4WlTwxSo5X+d +1jY9I8f61CRKVfIRgMvAZUhm2h6RnoVIgq7G7W3HdC3RT/f758njI7oIv5bhiyqp +gyKr3cYhmn0enjP+YtjY85m51q005qzRLfaTYiwMR4qyJW4ZOEPntPs20CD+e+Pi +2JLONpRdcsSrkqZusVjm5PFy2e5RyNFXTupUH2KVrgTRHL3GG2KWF5PmBdkhQfGG +1QIDAQABo0IwQDAdBgNVHQ4EFgQUS8+HouVQ94SdgI3kV3L8Jm53P6YwHwYDVR0j +BBgwFoAUY1u7KerT8m01BvAg77PUaot2S0EwDQYJKoZIhvcNAQELBQADggIBAE14 +YBa/stYwrsy/1iQ44NeQyYMPMdOC8TI1xrbW/9u1FllipECnFEGDK1N6mZ7xDEfG +un5dQ3jXQ7156Ge672374yUsN7FQ37mTyZos3Q2N/mOpVOnYJt5mIukx2MXBU3r3 +UP1Jpnf9rB4kdtWXa7b1CSTkM4kraige3wZhPELwESnm4t8C34MIzHBWPbHpft05 +WheDv9Zizfw+0pbJ+WNGnHF4PjR/wq9ymkLf89cqsbS9mOdPpWva8i0e7pqKnxzo +iz2ueQB4Z2Tbgp0G9ResA+2Zxk1iIQPbhtqNUZv6ROPiLAWdiVRysFJJf/19V+nZ +LIC0xw+amF6P51/fA95EGqElO4OLJTIGY27H761g7+FhTwfryLMHKknSxcfk7xoq +BMyBr7ARYnmpjee7yKOBUgSdpxb6YUcdGZwjCUIiIHlBII83DzcNILa5QvUkzMCh +xHYmPvvftJOjF8hwMfjA9MDFML9yWVm+CBNraNPh25U5uMOuIuyUBtSB5yEdPRhY +BJGrZEew0lLAWAqqASmGPDaWNBaA0HYqO70g4IyqBwIGNnaHSLVr/vT23BFRMyXf +wh5mtJmyyR3+c0po3vDX39mkIAZ2gZWprWa3Jw0dVs6cEujcVNdqeZlQw0RCa9wm +xioBxb1md2AplUQ2fG/KHnu2ZuxHA6MNYcwMJNgv +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIUKU+ta2rnJE/79L2Uxg4vFoF0RxYwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNDA5MjYxNDUzMzlaFw0zNDA5 +MjQxNDUzMzlaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQDSOuYqxNsP4Gm/EDauon/SAVWj8PIKFB1OpuguHm13 +b2l2G6hRuNnAmR6ewP22H/YoyYz1qRchH2qw1uizwnnSS0OY74CsJhd0PV6f4XRR +Y+6PotGDPu1fJJM4XI3HjWGdBkJSawZNWjP1dQRJPUHNRttSOrPsG3XT7TjfLjoK +jOelTqgwHUGE2n0AtQP7ZFQVn7LLBrukve8zMgivwEL1JFSlKppWFf0SUgpmQVE6 +3jTAQPPrI/B5z5Ys1j2jv7mJt3/UATcTGmvPTNv94SUrO6nC3TJxKHtR30MALteo +EgH/s2O0Ax44iDENgm9p6eb+GCyTWS/eHBAJ6SU76PRymiE57/0GOqyYewuEOuIU +FYd6+gglCMe+ayfhI20njHP6RTiQpRjFy+DM8+bkcS89q0sfFSFHR5oFNbAgUgyI +bGiaWb+DmUCwSFnS0HusSU2AECqzuwiyObD3rkoqBQMj+xl6SnJU6TTcB+WD/Z5G +tqu1zTMXpo3VRts3AQSGUuSaqbeG/S+38LX+fbjeTLa6SEGJfB7/H2s64vCO/0hR +M0KEXAaTyjx0PnNKYSlCIJyA9lYea21oByNc31tkUXQjmUQpSXYayrDwzR2JAXVY +rFJLNu1Q6sZ0WqT9fj06oTas7g1g3gcl18tIapeael2jJohth0RizBxKYuLYc6Pv +1QIDAQABo1MwUTAdBgNVHQ4EFgQUY1u7KerT8m01BvAg77PUaot2S0EwHwYDVR0j +BBgwFoAUY1u7KerT8m01BvAg77PUaot2S0EwDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAgEAB8BpmI3v2cNlXUjhf+rldaqDfq/IJ7Ri23kCBJW4VaoW +c0UrtvLC+K5m61I1iWSUYEK85/bPw2K5dn1e8w3Q2J460Yvc7/ZT7mucZlXQxfl3 +V7yqqQI7OMsY6FochYUL3+c32WQg5jllsLPlHAHBJlagf3uEqmVrvSExHNBQOVyE +/cs1i9DcTJF2A8JNPKilIObvRT103Qp2eFnW+EY9OUBb+TdQvPjxroLfK1SuOAe6 +bLPBxdgvA/0raHuXeDTNsNRICIU1X5eBfZwCXKe9lRVJpIsKTYeHDN/rEmfTtehB +vz8/KkCWqwPDn/YFkNAdg3TRjqW4oW2wZ+XqbTlR2qA7szE7oMAfHxNkintxMnNm +vD2/AAP6RUw16HZk0najFWPIG9gc+O1gSks6hwn9JilAPy8mn40H2D7cedU6Ew+T +CQ02+dw2+2FLKYr1eiYPlIELsAu8kmbrjwvwy2sCf3L4fxLtPRqXFuXB2Uer9zvy +tn+RK5hJkKo/YY37I9Y9x57rpCqUfFIeYWBub07x1620ujRkL1pJPxfRNBfyh42t +beuk/XQGIvPcIkbPnmsb4gGaiRMuw+mZ7isJDoQwHUmfqL1EpOYb5mLYHkIqKaCz +8t8wTdkimIVIFSxedy7cJCCWdQ/BCyTJoQpXD69PLPzxEi/YK9pB9S8qBtfefu4= +-----END CERTIFICATE----- diff --git a/dummy-chain-cert.pem.license b/dummy-chain-cert.pem.license new file mode 100644 index 0000000..615ea2b --- /dev/null +++ b/dummy-chain-cert.pem.license @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT diff --git a/dummy-child-key.pem b/dummy-child-key.pem new file mode 100644 index 0000000..2a8639b --- /dev/null +++ b/dummy-child-key.pem @@ -0,0 +1,52 @@ +-----BEGIN PRIVATE KEY----- +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 +S++xGnGHEUSEl7cWnO8k0RyJzzc= +-----END PRIVATE KEY----- diff --git a/dummy-child-key.pem.license b/dummy-child-key.pem.license new file mode 100644 index 0000000..615ea2b --- /dev/null +++ b/dummy-child-key.pem.license @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: Copyright (c) 2022-2024 The go-mail Authors +// +// SPDX-License-Identifier: MIT diff --git a/encoding.go b/encoding.go index 9ed5666..8c38cea 100644 --- a/encoding.go +++ b/encoding.go @@ -171,17 +171,20 @@ const ( // TypeTextPlain represents the MIME type for plain text content. TypeTextPlain ContentType = "text/plain" + + // typeSMimeSigned represents the MIME type for S/MIME singed messages. + typeSMimeSigned ContentType = `application/pkcs7-signature; name="smime.p7s"` ) const ( // MIMEAlternative MIMEType represents a MIME multipart/alternative type, used for emails with multiple versions. MIMEAlternative MIMEType = "alternative" - // MIMEMixed MIMEType represents a MIME multipart/mixed type used for emails containing different types of content. MIMEMixed MIMEType = "mixed" - // MIMERelated MIMEType represents a MIME multipart/related type, used for emails with related content entities. MIMERelated MIMEType = "related" + // MIMESMime MIMEType represents a MIME multipart/signed type, used for siging emails with S/MIME. + MIMESMime MIMEType = `signed; protocol="application/pkcs7-signature"; micalg=sha-256` ) // String satisfies the fmt.Stringer interface for the Charset type. @@ -219,3 +222,8 @@ func (c ContentType) String() string { func (e Encoding) String() string { return string(e) } + +// String is a standard method to convert an MIMEType into a printable format +func (e MIMEType) String() string { + return string(e) +} diff --git a/encoding_test.go b/encoding_test.go index 14711b7..4c7778a 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"`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -121,3 +126,24 @@ func TestCharset_String(t *testing.T) { }) } } + +// TestContentType_String tests the mime type method of the MIMEType object +func TestMimeType_String(t *testing.T) { + tests := []struct { + mt MIMEType + want string + }{ + {MIMEAlternative, "alternative"}, + {MIMEMixed, "mixed"}, + {MIMERelated, "related"}, + {MIMESMime, `signed; protocol="application/pkcs7-signature"; micalg=sha-256`}, + } + for _, tt := range tests { + t.Run(tt.mt.String(), func(t *testing.T) { + if tt.mt.String() != tt.want { + t.Errorf("wrong string for mime type returned. Expected: %s, got: %s", + tt.want, tt.mt.String()) + } + }) + } +} diff --git a/go.mod b/go.mod index 4fb64a8..24f14bb 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ module github.com/wneessen/go-mail go 1.16 require ( + go.mozilla.org/pkcs7 v0.9.0 golang.org/x/crypto v0.28.0 golang.org/x/text v0.19.0 ) diff --git a/go.sum b/go.sum index 8e6bffc..f917906 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,8 @@ 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= +go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= +go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= 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= @@ -63,4 +65,4 @@ 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= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= \ No newline at end of file diff --git a/msg.go b/msg.go index 61feda1..60f758f 100644 --- a/msg.go +++ b/msg.go @@ -7,6 +7,8 @@ package mail import ( "bytes" "context" + "crypto/rsa" + "crypto/x509" "embed" "errors" "fmt" @@ -24,7 +26,7 @@ import ( ) var ( - // ErrNoFromAddress indicates that the FROM address is not set, which is required. + // ErrNoFromAddress should be used when a FROM address is requested but not set ErrNoFromAddress = errors.New("no FROM address set") // ErrNoRcptAddresses indicates that no recipient addresses have been set. @@ -144,6 +146,9 @@ type Msg struct { // // This can be useful in scenarios where headers are conditionally passed based on receipt - i. e. SMTP proxies. noDefaultUserAgent bool + + // SMime represents a middleware used to sign messages with S/MIME + sMime *SMime } // SendmailPath is the default system path to the sendmail binary - at least on standard Unix-like OS. @@ -333,8 +338,16 @@ func WithNoDefaultUserAgent() MsgOption { } } -// SetCharset sets or overrides the currently set encoding charset of the Msg. -// +// SignWithSMime configures the Msg to be signed with S/MIME +func (m *Msg) SignWithSMime(privateKey *rsa.PrivateKey, certificate *x509.Certificate, intermediateCertificate *x509.Certificate) error { + sMime, err := newSMime(privateKey, certificate, intermediateCertificate) + if err != nil { + return err + } + m.sMime = sMime + return nil +} + // This method allows you to specify a character set for the email message. The charset is // important for ensuring that the content of the message is correctly interpreted by // mail clients. Common charset values include UTF-8, ISO-8859-1, and others. If a charset @@ -2115,6 +2128,38 @@ func (m *Msg) applyMiddlewares(msg *Msg) *Msg { return msg } +// signMessage sign the Msg with S/MIME +func (m *Msg) signMessage(msg *Msg) (*Msg, error) { + signedPart := msg.GetParts()[0] + body, err := signedPart.GetContent() + if err != nil { + return nil, err + } + + signaturePart, err := m.createSignaturePart(signedPart.GetEncoding(), signedPart.GetContentType(), signedPart.GetCharset(), body) + if err != nil { + return nil, err + } + + m.parts = append(m.parts, signaturePart) + + return m, err +} + +// createSignaturePart creates an additional part that be used for storing the S/MIME signature of the given body +func (m *Msg) createSignaturePart(encoding Encoding, contentType ContentType, charSet Charset, body []byte) (*Part, error) { + message := m.sMime.prepareMessage(encoding, contentType, charSet, body) + signedMessage, err := m.sMime.signMessage(message) + if err != nil { + return nil, err + } + + signaturePart := m.newPart(typeSMimeSigned, WithPartEncoding(EncodingB64), WithSMimeSinging()) + signaturePart.SetContent(*signedMessage) + + return signaturePart, nil +} + // WriteTo writes the formatted Msg into the given io.Writer and satisfies the io.WriterTo interface. // // This method writes the email message, including its headers, body, and attachments, to the provided @@ -2132,7 +2177,18 @@ func (m *Msg) applyMiddlewares(msg *Msg) *Msg { // - https://datatracker.ietf.org/doc/html/rfc5322 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.hasSMime() { + signedMsg, err := m.signMessage(msg) + if err != nil { + return 0, err + } + msg = signedMsg + } + + mw.writeMsg(msg) + return mw.bytesWritten, mw.err } @@ -2496,7 +2552,7 @@ func (m *Msg) hasAlt() bool { count++ } } - return count > 1 && m.pgptype == 0 + return count > 1 && m.pgptype == 0 && !m.hasSMime() } // hasMixed returns true if the Msg has mixed parts. @@ -2514,6 +2570,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. // // This method checks whether the message contains related parts, such as inline embedded files diff --git a/msg_test.go b/msg_test.go index 7fcbd99..7842cda 100644 --- a/msg_test.go +++ b/msg_test.go @@ -1907,6 +1907,39 @@ func TestMsg_hasAlt(t *testing.T) { } } +// TestMsg_hasAlt tests the hasAlt() method of the Msg with active S/MIME +func TestMsg_hasAltWithSMime(t *testing.T) { + privateKey, certificate, intermediateCertificate, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("failed to laod dummy crypto material. Cause: %v", err) + } + m := NewMsg() + m.SetBodyString(TypeTextPlain, "Plain") + m.AddAlternativeString(TypeTextHTML, "HTML") + if err := m.SignWithSMime(privateKey, certificate, intermediateCertificate); err != nil { + t.Errorf("failed to init smime configuration") + } + if m.hasAlt() { + t.Errorf("mail has alternative parts and S/MIME is active, but hasAlt() returned true") + } +} + +// TestMsg_hasSMime tests the hasSMime() method of the Msg +func TestMsg_hasSMime(t *testing.T) { + privateKey, certificate, intermediateCertificate, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("failed to laod dummy crypto material. Cause: %v", err) + } + m := NewMsg() + if err := m.SignWithSMime(privateKey, certificate, intermediateCertificate); err != nil { + t.Errorf("failed to init smime configuration") + } + m.SetBodyString(TypeTextPlain, "Plain") + if !m.hasSMime() { + t.Errorf("mail has smime configured but hasSMime() returned true") + } +} + // TestMsg_hasRelated tests the hasRelated() method of the Msg func TestMsg_hasRelated(t *testing.T) { m := NewMsg() @@ -1974,6 +2007,70 @@ func TestMsg_WriteToSkipMiddleware(t *testing.T) { } } +// TestMsg_WriteToWithSMIME tests the WriteTo() method of the Msg +func TestMsg_WriteToWithSMIME(t *testing.T) { + privateKey, certificate, intermediateCertificate, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("failed to laod dummy crypto material. Cause: %v", err) + } + + m := NewMsg() + m.Subject("This is a test") + m.SetBodyString(TypeTextPlain, "Plain") + if err := m.SignWithSMime(privateKey, certificate, intermediateCertificate); err != nil { + t.Errorf("failed to init smime configuration") + } + + wbuf := bytes.Buffer{} + if _, err = m.WriteTo(&wbuf); err != nil { + t.Errorf("WriteTo() failed: %s", err) + } + + result := wbuf.String() + boundary := result[strings.LastIndex(result, "--")-60 : strings.LastIndex(result, "--")] + if strings.Count(result, boundary) != 4 { + t.Errorf("WriteTo() failed. False number of boundaries found") + } + + parts := strings.Split(result, fmt.Sprintf("--%s", boundary)) + if len(parts) != 4 { + t.Errorf("WriteTo() failed. False number of parts found") + } + + preamble := parts[0] + if !strings.Contains(preamble, "MIME-Version: 1.0") { + t.Errorf("WriteTo() failed. Unable to find MIME-Version") + } + if !strings.Contains(preamble, "Subject: This is a test") { + t.Errorf("WriteTo() failed. Unable to find subject") + } + if !strings.Contains(preamble, fmt.Sprintf("Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=sha-256;\r\n boundary=%s", boundary)) { + t.Errorf("WriteTo() failed. Unable to find Content-Type") + } + + signedData := parts[1] + if !strings.Contains(signedData, "Content-Transfer-Encoding: quoted-printable") { + t.Errorf("WriteTo() failed. Unable to find Content-Transfer-Encoding") + } + if !strings.Contains(signedData, "Content-Type: text/plain; charset=UTF-8") { + t.Errorf("WriteTo() failed. Unable to find Content-Type") + } + if !strings.Contains(signedData, "Plain") { + t.Errorf("WriteTo() failed. Unable to find Content") + } + + signature := parts[2] + if !strings.Contains(signature, "Content-Transfer-Encoding: base64") { + t.Errorf("WriteTo() failed. Unable to find Content-Transfer-Encoding") + } + if !strings.Contains(signature, `application/pkcs7-signature; name="smime.p7s"`) { + t.Errorf("WriteTo() failed. Unable to find Content-Type") + } + if strings.Contains(signature, "Plain") { + t.Errorf("WriteTo() failed. Unable to find signature") + } +} + // TestMsg_WriteTo_fails tests the WriteTo() method of the Msg but with a failing body writer function func TestMsg_WriteTo_fails(t *testing.T) { m := NewMsg() @@ -3246,6 +3343,62 @@ func TestNewMsgWithNoDefaultUserAgent(t *testing.T) { } } +// TestSignWithSMime_ValidKeyPair tests WithSMimeSinging with given key pair +func TestSignWithSMime_ValidKeyPair(t *testing.T) { + privateKey, certificate, intermediateCertificate, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("failed to laod dummy crypto material. Cause: %v", err) + } + m := NewMsg() + if err := m.SignWithSMime(privateKey, certificate, intermediateCertificate); err != nil { + t.Errorf("failed to set sMime. Cause: %v", err) + } + if m.sMime.privateKey == nil { + t.Errorf("WithSMimeSinging() - no private key is given") + } + if m.sMime.certificate == nil { + t.Errorf("WithSMimeSinging() - no certificate is given") + } +} + +// TestSignWithSMime_InvalidPrivateKey tests WithSMimeSinging with given invalid private key +func TestSignWithSMime_InvalidPrivateKey(t *testing.T) { + m := NewMsg() + + err := m.SignWithSMime(nil, nil, nil) + if !errors.Is(err, ErrInvalidPrivateKey) { + t.Errorf("failed to pre-check SignWithSMime method values correctly: %s", err) + } +} + +// TestSignWithSMime_InvalidCertificate tests WithSMimeSinging with given invalid certificate +func TestSignWithSMime_InvalidCertificate(t *testing.T) { + privateKey, _, _, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("failed to laod dummy crypto material. Cause: %v", err) + } + m := NewMsg() + + err = m.SignWithSMime(privateKey, nil, nil) + if !errors.Is(err, ErrInvalidCertificate) { + t.Errorf("failed to pre-check SignWithSMime method values correctly: %s", err) + } +} + +// TestSignWithSMime_InvalidIntermediateCertificate tests WithSMimeSinging with given invalid intermediate certificate +func TestSignWithSMime_InvalidIntermediateCertificate(t *testing.T) { + privateKey, certificate, _, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("failed to laod dummy crypto material. Cause: %v", err) + } + m := NewMsg() + + err = m.SignWithSMime(privateKey, certificate, nil) + if !errors.Is(err, ErrInvalidIntermediateCertificate) { + t.Errorf("failed to pre-check SignWithSMime method values correctly: %s", err) + } +} + // Fuzzing tests func FuzzMsg_Subject(f *testing.F) { f.Add("Testsubject") @@ -3273,3 +3426,84 @@ func FuzzMsg_From(f *testing.F) { m.Reset() }) } + +// TestMsg_createSignaturePart tests the Msg.createSignaturePart method +func TestMsg_createSignaturePart(t *testing.T) { + privateKey, certificate, intermediateCertificate, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("failed to laod dummy crypto material. Cause: %v", err) + } + m := NewMsg() + if err := m.SignWithSMime(privateKey, certificate, intermediateCertificate); err != nil { + t.Errorf("failed to init smime configuration") + } + body := []byte("This is the body") + part, err := m.createSignaturePart(EncodingQP, TypeTextPlain, CharsetUTF7, body) + if err != nil { + t.Errorf("createSignaturePart() method failed.") + } + + if part.GetEncoding() != EncodingB64 { + t.Errorf("createSignaturePart() method failed. Expected encoding: %s, got: %s", EncodingB64, part.GetEncoding()) + } + if part.GetContentType() != typeSMimeSigned { + t.Errorf("createSignaturePart() method failed. Expected content type: %s, got: %s", typeSMimeSigned, part.GetContentType()) + } + if part.GetCharset() != CharsetUTF8 { + t.Errorf("createSignaturePart() method failed. Expected charset: %s, got: %s", CharsetUTF8, part.GetCharset()) + } + if content, err := part.GetContent(); err != nil || len(content) == len(body) { + t.Errorf("createSignaturePart() method failed. Expected content should not be equal: %s, got: %s", body, part.GetEncoding()) + } +} + +// TestMsg_signMessage tests the Msg.signMessage method +func TestMsg_signMessage(t *testing.T) { + privateKey, certificate, intermediateCertificate, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("failed to laod dummy crypto material. Cause: %v", err) + } + body := []byte("This is the body") + m := NewMsg() + m.SetBodyString(TypeTextPlain, string(body)) + if err := m.SignWithSMime(privateKey, certificate, intermediateCertificate); err != nil { + t.Errorf("failed to init smime configuration") + } + msg, err := m.signMessage(m) + if err != nil { + t.Errorf("createSignaturePart() method failed.") + } + + parts := msg.GetParts() + if len(parts) != 2 { + t.Errorf("createSignaturePart() method failed. Expected 2 parts, got: %d", len(parts)) + } + + signedPart := parts[0] + if signedPart.GetEncoding() != EncodingQP { + t.Errorf("createSignaturePart() method failed. Expected encoding: %s, got: %s", EncodingB64, signedPart.GetEncoding()) + } + if signedPart.GetContentType() != TypeTextPlain { + t.Errorf("createSignaturePart() method failed. Expected content type: %s, got: %s", typeSMimeSigned, signedPart.GetContentType()) + } + if signedPart.GetCharset() != CharsetUTF8 { + t.Errorf("createSignaturePart() method failed. Expected charset: %s, got: %s", CharsetUTF8, signedPart.GetCharset()) + } + if content, err := signedPart.GetContent(); err != nil || len(content) != len(body) { + t.Errorf("createSignaturePart() method failed. Expected content should be equal: %s, got: %s", body, content) + } + + signaturePart := parts[1] + if signaturePart.GetEncoding() != EncodingB64 { + t.Errorf("createSignaturePart() method failed. Expected encoding: %s, got: %s", EncodingB64, signaturePart.GetEncoding()) + } + if signaturePart.GetContentType() != typeSMimeSigned { + t.Errorf("createSignaturePart() method failed. Expected content type: %s, got: %s", typeSMimeSigned, signaturePart.GetContentType()) + } + if signaturePart.GetCharset() != CharsetUTF8 { + t.Errorf("createSignaturePart() method failed. Expected charset: %s, got: %s", CharsetUTF8, signaturePart.GetCharset()) + } + if content, err := signaturePart.GetContent(); err != nil || len(content) == len(body) { + t.Errorf("createSignaturePart() method failed. Expected content should not be equal: %s, got: %s", body, signaturePart.GetEncoding()) + } +} diff --git a/msgwriter.go b/msgwriter.go index ed2b41e..331138c 100644 --- a/msgwriter.go +++ b/msgwriter.go @@ -128,6 +128,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) @@ -174,6 +178,10 @@ func (mw *msgWriter) writeMsg(msg *Msg) { if msg.hasMixed() { mw.stopMP() } + + if msg.hasSMime() { + mw.stopMP() + } } // writeGenHeader writes out all generic headers to the msgWriter. @@ -314,7 +322,7 @@ func (mw *msgWriter) addFiles(files []*File, isAttachment bool) { } if mw.err == nil { - mw.writeBody(file.Writer, encoding) + mw.writeBody(file.Writer, encoding, false) } } } @@ -347,7 +355,12 @@ func (mw *msgWriter) writePart(part *Part, charset Charset) { if partCharset.String() == "" { partCharset = charset } - contentType := fmt.Sprintf("%s; charset=%s", part.contentType, partCharset) + + contentType := part.contentType.String() + if !part.IsSMimeSigned() { + contentType = strings.Join([]string{contentType, "; charset=", partCharset.String()}, "") + } + contentTransferEnc := part.encoding.String() if mw.depth == 0 { mw.writeHeader(HeaderContentType, contentType) @@ -363,7 +376,7 @@ func (mw *msgWriter) writePart(part *Part, charset Charset) { mimeHeader.Add(string(HeaderContentTransferEnc), contentTransferEnc) mw.newPart(mimeHeader) } - mw.writeBody(part.writeFunc, part.encoding) + mw.writeBody(part.writeFunc, part.encoding, part.smime) } // writeString writes a string into the msgWriter's io.Writer interface. @@ -438,7 +451,8 @@ func (mw *msgWriter) writeHeader(key Header, values ...string) { // Parameters: // - writeFunc: A function that writes the body content to the given io.Writer. // - encoding: The encoding type to use when writing the content (e.g., base64, quoted-printable). -func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encoding Encoding) { +// - singingWithSMime: Whether the msg should be signed with S/MIME or not. +func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encoding Encoding, singingWithSMime bool) { var writer io.Writer var encodedWriter io.WriteCloser var n int64 @@ -453,12 +467,11 @@ func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encodin lineBreaker := Base64LineBreaker{} lineBreaker.out = &writeBuffer - switch encoding { - case EncodingQP: + if encoding == EncodingQP { encodedWriter = quotedprintable.NewWriter(&writeBuffer) - case EncodingB64: + } else if encoding == EncodingB64 && !singingWithSMime { encodedWriter = base64.NewEncoder(base64.StdEncoding, &lineBreaker) - case NoEncoding: + } else if encoding == NoEncoding || singingWithSMime { _, err = writeFunc(&writeBuffer) if err != nil { mw.err = fmt.Errorf("bodyWriter function: %w", err) @@ -471,7 +484,7 @@ func (mw *msgWriter) writeBody(writeFunc func(io.Writer) (int64, error), encodin mw.bytesWritten += n } return - default: + } else { encodedWriter = quotedprintable.NewWriter(writer) } diff --git a/msgwriter_test.go b/msgwriter_test.go index a41e5d3..6e36c4b 100644 --- a/msgwriter_test.go +++ b/msgwriter_test.go @@ -154,3 +154,42 @@ func TestMsgWriter_writeMsg_PGP(t *testing.T) { t.Errorf("writeMsg failed. Expected PGP encoding header but didn't find it in message output") } } + +// TestMsgWriter_writeMsg_SMime tests the writeMsg method of the msgWriter with S/MIME types set +func TestMsgWriter_writeMsg_SMime(t *testing.T) { + privateKey, certificate, intermediateCertificate, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("failed to laod dummy crypto material. Cause: %v", err) + } + + m := NewMsg() + if err := m.SignWithSMime(privateKey, certificate, intermediateCertificate); err != nil { + t.Errorf("failed to init smime configuration") + } + _ = m.From(`"Toni Tester" `) + _ = m.To(`"Toni Receiver" `) + m.Subject("This is a subject") + m.SetBodyString(TypeTextPlain, "This is the body") + buf := bytes.Buffer{} + mw := &msgWriter{writer: &buf, charset: CharsetUTF8, encoder: mime.QEncoding} + mw.writeMsg(m) + ms := buf.String() + + if !strings.Contains(ms, "MIME-Version: 1.0") { + t.Errorf("writeMsg failed. Unable to find MIME-Version") + } + if !strings.Contains(ms, "Subject: This is a subject") { + t.Errorf("writeMsg failed. Unable to find subject") + } + if !strings.Contains(ms, "From: \"Toni Tester\" ") { + t.Errorf("writeMsg failed. Unable to find transmitter") + } + if !strings.Contains(ms, "To: \"Toni Receiver\" ") { + t.Errorf("writeMsg failed. Unable to find receiver") + } + + boundary := ms[strings.LastIndex(ms, "--")-60 : strings.LastIndex(ms, "--")] + if !strings.Contains(ms, fmt.Sprintf("Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=sha-256;\r\n boundary=%s", boundary)) { + t.Errorf("writeMsg failed. Unable to find Content-Type") + } +} diff --git a/part.go b/part.go index 5be8059..3fab652 100644 --- a/part.go +++ b/part.go @@ -24,6 +24,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 a byte slice. @@ -94,6 +95,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. // // This function sets the content of the Part by creating a new writeFunc that writes the @@ -146,6 +152,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. // // This function sets a new WriteFunc for the Part, replacing the existing one. The WriteFunc @@ -213,3 +224,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..c1a8ac7 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,33 @@ 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() + m.SetBodyString(TypeTextPlain, "This is a body!") + 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ß" diff --git a/smime.go b/smime.go new file mode 100644 index 0000000..405a5c1 --- /dev/null +++ b/smime.go @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "fmt" + "strings" + + "go.mozilla.org/pkcs7" +) + +var ( + // ErrInvalidPrivateKey should be used if private key is invalid + ErrInvalidPrivateKey = errors.New("invalid private key") + + // ErrInvalidCertificate should be used if the certificate is invalid + ErrInvalidCertificate = errors.New("invalid certificate") + + // ErrInvalidIntermediateCertificate should be used if the intermediate certificate is invalid + ErrInvalidIntermediateCertificate = errors.New("invalid intermediate 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") + + // ErrCouldNoEncodeToPEM should be used if the signature could not be encoded to PEM + ErrCouldNoEncodeToPEM = errors.New("could not encode to PEM") +) + +// SMime is used to sign messages with S/MIME +type SMime struct { + privateKey *rsa.PrivateKey + certificate *x509.Certificate + intermediateCertificate *x509.Certificate +} + +// NewSMime construct a new instance of SMime with provided parameters +// privateKey as *rsa.PrivateKey +// certificate as *x509.Certificate +// intermediateCertificate as *x509.Certificate +func newSMime(privateKey *rsa.PrivateKey, certificate *x509.Certificate, intermediateCertificate *x509.Certificate) (*SMime, error) { + if privateKey == nil { + return nil, ErrInvalidPrivateKey + } + + if certificate == nil { + return nil, ErrInvalidCertificate + } + + if intermediateCertificate == nil { + return nil, ErrInvalidIntermediateCertificate + } + + return &SMime{ + privateKey: privateKey, + certificate: certificate, + intermediateCertificate: intermediateCertificate, + }, nil +} + +// signMessage signs the message with S/MIME +func (sm *SMime) signMessage(message string) (*string, error) { + lines := parseLines([]byte(message)) + toBeSigned := lines.bytesFromLines([]byte("\r\n")) + + signedData, err := pkcs7.NewSignedData(toBeSigned) + signedData.SetDigestAlgorithm(pkcs7.OIDDigestAlgorithmSHA256) + if err != nil { + return nil, ErrCouldNotInitialize + } + + if err = signedData.AddSignerChain(sm.certificate, sm.privateKey, []*x509.Certificate{sm.intermediateCertificate}, pkcs7.SignerInfoConfig{}); err != nil { + return nil, ErrCouldNotAddSigner + } + + signedData.Detach() + + signatureDER, err := signedData.Finish() + if err != nil { + return nil, ErrCouldNotFinishSigning + } + + pemMsg, err := encodeToPEM(signatureDER) + if err != nil { + return nil, ErrCouldNoEncodeToPEM + } + + return pemMsg, nil +} + +// createMessage prepares the message that will be used for the sign method later +func (sm *SMime) prepareMessage(encoding Encoding, contentType ContentType, charset Charset, body []byte) string { + return fmt.Sprintf("Content-Transfer-Encoding: %v\r\nContent-Type: %v; charset=%v\r\n\r\n%v", encoding, contentType, charset, string(body)) +} + +// encodeToPEM uses the method pem.Encode from the standard library but cuts the typical PEM preamble +func encodeToPEM(msg []byte) (*string, error) { + block := &pem.Block{Bytes: msg} + + var arrayBuffer bytes.Buffer + if err := pem.Encode(&arrayBuffer, block); err != nil { + return nil, err + } + + r := arrayBuffer.String() + r = strings.TrimPrefix(r, "-----BEGIN -----") + r = strings.Trim(r, "\n") + r = strings.TrimSuffix(r, "-----END -----") + r = strings.Trim(r, "\n") + + return &r, nil +} + +// line is the representation of one line of the message that will be used for signing purposes +type line struct { + line []byte + endOfLine []byte +} + +// lines is the representation of a message that will be used for signing purposes +type lines []line + +// bytesFromLines creates the line representation with the given endOfLine char +func (ls lines) bytesFromLines(sep []byte) []byte { + var raw []byte + for i := range ls { + raw = append(raw, ls[i].line...) + if len(ls[i].endOfLine) != 0 && sep != nil { + raw = append(raw, sep...) + } else { + raw = append(raw, ls[i].endOfLine...) + } + } + return raw +} + +// parseLines constructs the lines representation of a given message +func parseLines(raw []byte) lines { + oneLine := line{raw, nil} + lines := lines{oneLine} + lines = lines.splitLine([]byte("\r\n")) + lines = lines.splitLine([]byte("\r")) + lines = lines.splitLine([]byte("\n")) + return lines +} + +// splitLine uses the given endOfLine to split the given line +func (ls lines) splitLine(sep []byte) lines { + nl := lines{} + for _, l := range ls { + split := bytes.Split(l.line, sep) + if len(split) > 1 { + for i := 0; i < len(split)-1; i++ { + nl = append(nl, line{split[i], sep}) + } + nl = append(nl, line{split[len(split)-1], l.endOfLine}) + } else { + nl = append(nl, l) + } + } + return nl +} diff --git a/smime_test.go b/smime_test.go new file mode 100644 index 0000000..9c5b121 --- /dev/null +++ b/smime_test.go @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "bytes" + "encoding/base64" + "fmt" + "strings" + "testing" +) + +// TestNewSMime tests the newSMime method +func TestNewSMime(t *testing.T) { + privateKey, certificate, intermediateCertificate, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("Error getting dummy crypto material: %s", err) + } + + sMime, err := newSMime(privateKey, certificate, intermediateCertificate) + if err != nil { + t.Errorf("Error creating new SMime from keyPair: %s", err) + } + + if sMime.privateKey != privateKey { + t.Errorf("NewSMime() did not return the same private key") + } + if sMime.certificate != certificate { + t.Errorf("NewSMime() did not return the same certificate") + } + if sMime.intermediateCertificate != intermediateCertificate { + t.Errorf("NewSMime() did not return the same intermedidate certificate") + } +} + +// TestSign tests the sign method +func TestSign(t *testing.T) { + privateKey, certificate, intermediateCertificate, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("Error getting dummy crypto material: %s", err) + } + + sMime, err := newSMime(privateKey, certificate, intermediateCertificate) + if err != nil { + t.Errorf("Error creating new SMime from keyPair: %s", err) + } + + message := "This is a test message" + singedMessage, err := sMime.signMessage(message) + if err != nil { + t.Errorf("Error creating singed message: %s", err) + } + + if *singedMessage == message { + t.Errorf("Sign() did not work") + } +} + +// TestPrepareMessage tests the createMessage method +func TestPrepareMessage(t *testing.T) { + privateKey, certificate, intermediateCertificate, err := getDummyCryptoMaterial() + if err != nil { + t.Errorf("Error getting dummy crypto material: %s", err) + } + + sMime, err := newSMime(privateKey, certificate, intermediateCertificate) + if err != nil { + t.Errorf("Error creating new SMime from keyPair: %s", err) + } + + encoding := EncodingB64 + contentType := TypeTextPlain + charset := CharsetUTF8 + body := []byte("This is the body!") + result := sMime.prepareMessage(encoding, contentType, charset, body) + + if !strings.Contains(result, encoding.String()) { + t.Errorf("createMessage() did not return the correct encoding") + } + if !strings.Contains(result, contentType.String()) { + t.Errorf("createMessage() did not return the correct contentType") + } + if !strings.Contains(result, string(body)) { + t.Errorf("createMessage() did not return the correct body") + } + if result != fmt.Sprintf("Content-Transfer-Encoding: %s\r\nContent-Type: %s; charset=%s\r\n\r\n%s", encoding, contentType, charset, string(body)) { + t.Errorf("createMessage() did not sucessfully create the message") + } +} + +// TestEncodeToPEM tests the encodeToPEM method +func TestEncodeToPEM(t *testing.T) { + message := []byte("This is a test message") + + pemMessage, err := encodeToPEM(message) + if err != nil { + t.Errorf("Error encoding message: %s", err) + } + + base64Encoded := base64.StdEncoding.EncodeToString(message) + if *pemMessage != base64Encoded { + t.Errorf("encodeToPEM() did not work") + } +} + +// TestBytesFromLines tests the bytesFromLines method +func TestBytesFromLines(t *testing.T) { + ls := lines{ + {line: []byte("Hello"), endOfLine: []byte("\n")}, + {line: []byte("World"), endOfLine: []byte("\n")}, + } + expected := []byte("Hello\nWorld\n") + + result := ls.bytesFromLines([]byte("\n")) + if !bytes.Equal(result, expected) { + t.Errorf("Expected %s, but got %s", expected, result) + } +} + +// FuzzBytesFromLines tests the bytesFromLines method with fuzzing +func FuzzBytesFromLines(f *testing.F) { + f.Add([]byte("Hello"), []byte("\n")) + f.Fuzz(func(t *testing.T, lineData, sep []byte) { + ls := lines{ + {line: lineData, endOfLine: sep}, + } + _ = ls.bytesFromLines(sep) + }) +} + +// TestParseLines tests the parseLines method +func TestParseLines(t *testing.T) { + input := []byte("Hello\r\nWorld\nHello\rWorld") + expected := lines{ + {line: []byte("Hello"), endOfLine: []byte("\r\n")}, + {line: []byte("World"), endOfLine: []byte("\n")}, + {line: []byte("Hello"), endOfLine: []byte("\r")}, + {line: []byte("World"), endOfLine: []byte("")}, + } + + result := parseLines(input) + if len(result) != len(expected) { + t.Errorf("Expected %d lines, but got %d", len(expected), len(result)) + } + + for i := range result { + if !bytes.Equal(result[i].line, expected[i].line) || !bytes.Equal(result[i].endOfLine, expected[i].endOfLine) { + t.Errorf("Line %d mismatch. Expected line: %s, endOfLine: %s, got line: %s, endOfLine: %s", + i, expected[i].line, expected[i].endOfLine, result[i].line, result[i].endOfLine) + } + } +} + +// FuzzParseLines tests the parseLines method with fuzzing +func FuzzParseLines(f *testing.F) { + f.Add([]byte("Hello\nWorld\r\nAnother\rLine")) + f.Fuzz(func(t *testing.T, input []byte) { + _ = parseLines(input) + }) +} + +// TestSplitLine tests the splitLine method +func TestSplitLine(t *testing.T) { + ls := lines{ + {line: []byte("Hello\r\nWorld\r\nAnotherLine"), endOfLine: []byte("")}, + } + expected := lines{ + {line: []byte("Hello"), endOfLine: []byte("\r\n")}, + {line: []byte("World"), endOfLine: []byte("\r\n")}, + {line: []byte("AnotherLine"), endOfLine: []byte("")}, + } + + result := ls.splitLine([]byte("\r\n")) + if len(result) != len(expected) { + t.Errorf("Expected %d lines, but got %d", len(expected), len(result)) + } + + for i := range result { + if !bytes.Equal(result[i].line, expected[i].line) || !bytes.Equal(result[i].endOfLine, expected[i].endOfLine) { + t.Errorf("Line %d mismatch. Expected line: %s, endOfLine: %s, got line: %s, endOfLine: %s", + i, expected[i].line, expected[i].endOfLine, result[i].line, result[i].endOfLine) + } + } +} + +// FuzzSplitLine tests the parseLsplitLineines method with fuzzing +func FuzzSplitLine(f *testing.F) { + f.Add([]byte("Hello\r\nWorld"), []byte("\r\n")) + f.Fuzz(func(t *testing.T, input, sep []byte) { + ls := lines{ + {line: input, endOfLine: []byte("")}, + } + _ = ls.splitLine(sep) + }) +} diff --git a/util_test.go b/util_test.go new file mode 100644 index 0000000..f14f010 --- /dev/null +++ b/util_test.go @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors +// +// SPDX-License-Identifier: MIT + +package mail + +import ( + "crypto/rsa" + "crypto/tls" + "crypto/x509" +) + +const ( + certFilePath = "dummy-chain-cert.pem" + keyFilePath = "dummy-child-key.pem" +) + +// getDummyCryptoMaterial loads a certificate and a private key form local disk for testing purposes +func getDummyCryptoMaterial() (*rsa.PrivateKey, *x509.Certificate, *x509.Certificate, error) { + keyPair, err := tls.LoadX509KeyPair(certFilePath, keyFilePath) + if err != nil { + return nil, nil, nil, err + } + + privateKey := keyPair.PrivateKey.(*rsa.PrivateKey) + + certificate, err := x509.ParseCertificate(keyPair.Certificate[0]) + if err != nil { + return nil, nil, nil, err + } + + intermediateCertificate, err := x509.ParseCertificate(keyPair.Certificate[1]) + if err != nil { + return nil, nil, nil, err + } + + return privateKey, certificate, intermediateCertificate, nil +}