Skip to content

Commit

Permalink
Merge pull request #406 from wneessen/chore/smime-cleanup
Browse files Browse the repository at this point in the history
Refactor and extend S/MIME signing support
  • Loading branch information
wneessen authored Jan 8, 2025
2 parents 1ad1c35 + 671fd28 commit 42ce0bf
Show file tree
Hide file tree
Showing 25 changed files with 1,129 additions and 1,086 deletions.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ SPDX-License-Identifier: MIT
[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/wneessen/go-mail/badge)](https://securityscorecards.dev/viewer/?uri=github.com/wneessen/go-mail)
<a href="https://ko-fi.com/D1D24V9IX"><img src="https://uploads-ssl.webflow.com/5c14e387dab576fe667689cf/5cbed8a4ae2b88347c06c923_BuyMeACoffee_blue.png" height="20" alt="buy ma a coffee"></a>

<p align="center"><img src="./assets/gopher2.svg" width="250" alt="go-mail logo"/></p>
<p style="text-align: center"><img src="./assets/gopher2.svg" width="250" alt="go-mail logo"/></p>

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.
Expand Down Expand Up @@ -65,7 +65,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
* [X] S/MIME message signing support (Experimental)

go-mail works like a programatic email client and provides lots of methods and functionalities you would consider
standard in a MUA.
Expand Down Expand Up @@ -113,7 +113,7 @@ contributed ot the project. Big thanks to all of them for improving the go-mail
code, reviewing code, writing documenation or helping to translate the website):

<a href="https://github.com/wneessen/go-mail/graphs/contributors">
<img src="https://contrib.rocks/image?repo=wneessen/go-mail" />
<img alt="image of contributors" src="https://contrib.rocks/image?repo=wneessen/go-mail" />
</a>

A huge thank you also goes to [Maria Letta](https://github.com/MariaLetta) for designing our super cool go-mail logo!
Expand Down
22 changes: 16 additions & 6 deletions encoding.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,19 +172,22 @@ 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"`
// 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 represents a MIME multipart/mixed type used fork 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`

// MIMESMIMESigned MIMEType represents a MIME multipart/signed type, used for siging emails with S/MIME.
MIMESMIMESigned MIMEType = `signed; protocol="application/pkcs7-signature"; micalg=sha-256`
)

// String satisfies the fmt.Stringer interface for the Charset type.
Expand Down Expand Up @@ -223,7 +226,14 @@ func (e Encoding) String() string {
return string(e)
}

// String is a standard method to convert an MIMEType into a printable format
// String satisfies the fmt.Stringer interface for the MIMEType type.
// It converts an MIMEType into a printable format.
//
// This method returns the string representation of the MIMEType, which can be used
// for displaying or logging purposes.
//
// Returns:
// - A string representation of the MIMEType.
func (e MIMEType) String() string {
return string(e)
}
4 changes: 2 additions & 2 deletions encoding_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ func TestContentType_String(t *testing.T) {
},

{
"ContentType: pkcs7-signature", typeSMimeSigned,
"ContentType: pkcs7-signature", TypeSMIMESigned,
`application/pkcs7-signature; name="smime.p7s"`,
},
}
Expand Down Expand Up @@ -136,7 +136,7 @@ func TestMimeType_String(t *testing.T) {
{MIMEAlternative, "alternative"},
{MIMEMixed, "mixed"},
{MIMERelated, "related"},
{MIMESMime, `signed; protocol="application/pkcs7-signature"; micalg=sha-256`},
{MIMESMIMESigned, `signed; protocol="application/pkcs7-signature"; micalg=sha-256`},
}
for _, tt := range tests {
t.Run(tt.mt.String(), func(t *testing.T) {
Expand Down
98 changes: 29 additions & 69 deletions pkcs7.go → internal/pkcs7/pkcs7.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,26 @@
// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
// SPDX-FileCopyrightText: Copyright (c) 2015 Andrew Smith
// SPDX-FileCopyrightText: Copyright (c) 2017-2024 The mozilla services project (https://github.com/mozilla-services)
// SPDX-FileCopyrightText: Copyright (c) 2024-2025 The go-mail Authors
//
// Partially forked from https://github.com/mozilla-services/pkcs7, which in turn is also a fork
// of https://github.com/fullsailor/pkcs7.
// Use of the forked source code is, same as go-mail, governed by a MIT license.
//
// go-mail specific modifications by the go-mail Authors.
// Licensed under the MIT License.
// See [PROJECT ROOT]/LICENSES directory for more information.
//
// SPDX-License-Identifier: MIT

package mail
package pkcs7

import (
"bytes"
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
_ "crypto/sha256" // for crypto.SHA256
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
Expand All @@ -18,8 +29,6 @@ import (
"math/big"
"sort"
"time"

_ "crypto/sha256" // for crypto.SHA256
)

var (
Expand All @@ -35,39 +44,12 @@ var (
OIDEncryptionAlgorithmRSASHA256 = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11}
)

// ErrUnsupportedAlgorithm tells you when our quick dev assumptions have failed
var ErrUnsupportedAlgorithm = errors.New("pkcs7: cannot decrypt data: only RSA, DES, DES-EDE3, AES-256-CBC and AES-128-GCM supported")

// PKCS7 Represents a PKCS7 structure
type PKCS7 struct {
Content []byte
Certificates []*x509.Certificate
CRLs []x509.RevocationList
Signers []signerInfo
raw interface{}
}

// GetOnlySigner returns an x509.Certificate for the first signer of the signed
// data payload. If there are more or less than one signer, nil is returned
func (p7 *PKCS7) GetOnlySigner() *x509.Certificate {
if len(p7.Signers) != 1 {
return nil
}
signer := p7.Signers[0]
return getCertFromCertsByIssuerAndSerial(p7.Certificates, signer.IssuerAndSerialNumber)
}

// UnmarshalSignedAttribute decodes a single attribute from the signer info
func (p7 *PKCS7) UnmarshalSignedAttribute(attributeType asn1.ObjectIdentifier, out interface{}) error {
sd, ok := p7.raw.(signedData)
if !ok {
return errors.New("pkcs7: payload is not signedData content")
}
if len(sd.SignerInfos) < 1 {
return errors.New("pkcs7: payload has no signers")
}
attributes := sd.SignerInfos[0].AuthenticatedAttributes
return unmarshalAttribute(attributes, attributeType, out)
}

type contentInfo struct {
Expand Down Expand Up @@ -218,8 +200,8 @@ type SignedData struct {
digestOid asn1.ObjectIdentifier
}

// newSignedData initializes a SignedData with content
func newSignedData(data []byte) (*SignedData, error) {
// NewSignedData initializes a SignedData with content
func NewSignedData(data []byte) (*SignedData, error) {
content, err := asn1.Marshal(data)
if err != nil {
return nil, err
Expand All @@ -235,8 +217,8 @@ func newSignedData(data []byte) (*SignedData, error) {
return &SignedData{sd: sd, data: data, digestOid: OIDDigestAlgorithmSHA256}, nil
}

// addSigner is a wrapper around AddSignerChain() that adds a signer without any parent.
func (sd *SignedData) addSigner(cert *x509.Certificate, pkey crypto.PrivateKey, config SignerInfoConfig) error {
// AddSigner is a wrapper around AddSignerChain() that adds a signer without any parent.
func (sd *SignedData) AddSigner(cert *x509.Certificate, pkey crypto.PrivateKey, config SignerInfoConfig) error {
var parents []*x509.Certificate
return sd.addSignerChain(cert, pkey, parents, config)
}
Expand Down Expand Up @@ -318,19 +300,19 @@ func (sd *SignedData) addSignerChain(ee *x509.Certificate, pkey crypto.PrivateKe
return nil
}

// addCertificate adds the certificate to the payload. Useful for parent certificates
func (sd *SignedData) addCertificate(cert *x509.Certificate) {
// AddCertificate adds the certificate to the payload. Useful for parent certificates
func (sd *SignedData) AddCertificate(cert *x509.Certificate) {
sd.certs = append(sd.certs, cert)
}

// detach removes content from the signed data struct to make it a detached signature.
// Detach removes content from the signed data struct to make it a detached signature.
// This must be called right before Finish()
func (sd *SignedData) detach() {
func (sd *SignedData) Detach() {
sd.sd.ContentInfo = contentInfo{ContentType: OIDData}
}

// finish marshals the content and its signers
func (sd *SignedData) finish() ([]byte, error) {
// Finish marshals the content and its signers
func (sd *SignedData) Finish() ([]byte, error) {
sd.sd.Certificates = marshalCertificates(sd.certs)
inner, err := asn1.Marshal(sd.sd)
if err != nil {
Expand Down Expand Up @@ -364,7 +346,7 @@ func verifyPartialChain(cert *x509.Certificate, parents []*x509.Certificate) err

// getOIDForEncryptionAlgorithm takes the private key type of the signer and
// the OID of a digest algorithm to return the appropriate signerInfo.DigestEncryptionAlgorithm
func getOIDForEncryptionAlgorithm(pkey crypto.PrivateKey, OIDDigestAlg asn1.ObjectIdentifier) (asn1.ObjectIdentifier, error) {
func getOIDForEncryptionAlgorithm(pkey crypto.PrivateKey, _ asn1.ObjectIdentifier) (asn1.ObjectIdentifier, error) {
switch pkey.(type) {
case *rsa.PrivateKey:
return OIDEncryptionAlgorithmRSASHA256, nil
Expand All @@ -383,11 +365,12 @@ func signAttributes(attrs []attribute, pkey crypto.PrivateKey, hash crypto.Hash)
h := hash.New()
h.Write(attrBytes)
hashed := h.Sum(nil)
switch priv := pkey.(type) {
case *rsa.PrivateKey:
return rsa.SignPKCS1v15(rand.Reader, priv, crypto.SHA256, hashed)

key, ok := pkey.(crypto.Signer)
if !ok {
return nil, errors.New("pkcs7: private key does not implement crypto.Signer")
}
return nil, ErrUnsupportedAlgorithm
return key.Sign(rand.Reader, hashed, hash)
}

// concat and wraps the certificates in the RawValue structure
Expand Down Expand Up @@ -427,26 +410,3 @@ func marshalAttributes(attrs []attribute) ([]byte, error) {
}
return raw.Bytes, nil
}

func getCertFromCertsByIssuerAndSerial(certs []*x509.Certificate, ias issuerAndSerial) *x509.Certificate {
for _, cert := range certs {
if isCertMatchForIssuerAndSerial(cert, ias) {
return cert
}
}
return nil
}

func isCertMatchForIssuerAndSerial(cert *x509.Certificate, ias issuerAndSerial) bool {
return cert.SerialNumber.Cmp(ias.SerialNumber) == 0 && bytes.Equal(cert.RawIssuer, ias.IssuerName.FullBytes)
}

func unmarshalAttribute(attrs []attribute, attributeType asn1.ObjectIdentifier, out interface{}) error {
for _, attr := range attrs {
if attr.Type.Equal(attributeType) {
_, err := asn1.Unmarshal(attr.Value.Bytes, out)
return err
}
}
return errors.New("pkcs7: attribute type not in attributes")
}
45 changes: 27 additions & 18 deletions pkcs7_test.go → internal/pkcs7/pkcs7_test.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
// SPDX-FileCopyrightText: 2022-2023 The go-mail Authors
// SPDX-FileCopyrightText: Copyright (c) 2015 Andrew Smith
// SPDX-FileCopyrightText: Copyright (c) 2017-2024 The mozilla services project (https://github.com/mozilla-services)
// SPDX-FileCopyrightText: Copyright (c) 2024-2025 The go-mail Authors
//
// Partially forked from https://github.com/mozilla-services/pkcs7, which in turn is also a fork
// of https://github.com/fullsailor/pkcs7.
// Use of the forked source code is, same as go-mail, governed by a MIT license.
//
// go-mail specific modifications by the go-mail Authors.
// Licensed under the MIT License.
// See [PROJECT ROOT]/LICENSES directory for more information.
//
// SPDX-License-Identifier: MIT

package mail
package pkcs7

import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"testing"
"time"
)
Expand All @@ -26,54 +35,54 @@ func TestSign_E2E(t *testing.T) {
}
content := []byte("Hello World")
for _, testDetach := range []bool{false, true} {
toBeSigned, err := newSignedData(content)
if err != nil {
toBeSigned, serr := NewSignedData(content)
if serr != nil {
t.Fatalf("Cannot initialize signed data: %s", err)
}
if err := toBeSigned.addSigner(cert.Certificate, cert.PrivateKey, SignerInfoConfig{}); err != nil {
if serr = toBeSigned.AddSigner(cert.Certificate, cert.PrivateKey, SignerInfoConfig{}); serr != nil {
t.Fatalf("Cannot add signer: %s", err)
}
if testDetach {
t.Log("Testing detached signature")
toBeSigned.detach()
} else {
t.Log("Testing attached signature")
toBeSigned.Detach()
}
signed, err := toBeSigned.finish()
if err != nil {
signed, serr := toBeSigned.Finish()
if serr != nil {
t.Fatalf("Cannot finish signing data: %s", err)
}
if err := pem.Encode(os.Stdout, &pem.Block{Type: "PKCS7", Bytes: signed}); err != nil {
buf := bytes.NewBuffer(nil)
if serr = pem.Encode(buf, &pem.Block{Type: "PKCS7", Bytes: signed}); serr != nil {
t.Fatalf("Cannot write signed data: %s", err)
}
}
}

// certKeyPair represents a pair of an x509 certificate and its corresponding RSA private key.
type certKeyPair struct {
Certificate *x509.Certificate
PrivateKey *rsa.PrivateKey
}

// createTestCertificate generates a test certificate and private key pair.
func createTestCertificate() (*certKeyPair, error) {
buf := bytes.NewBuffer(nil)
signer, err := createTestCertificateByIssuer("Eddard Stark", nil)
if err != nil {
return nil, err
}
fmt.Println("Created root cert")
if err := pem.Encode(os.Stdout, &pem.Block{Type: "CERTIFICATE", Bytes: signer.Certificate.Raw}); err != nil {
if err = pem.Encode(buf, &pem.Block{Type: "CERTIFICATE", Bytes: signer.Certificate.Raw}); err != nil {
return nil, err
}
pair, err := createTestCertificateByIssuer("Jon Snow", signer)
if err != nil {
return nil, err
}
fmt.Println("Created signer cert")
if err := pem.Encode(os.Stdout, &pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate.Raw}); err != nil {
if err = pem.Encode(buf, &pem.Block{Type: "CERTIFICATE", Bytes: pair.Certificate.Raw}); err != nil {
return nil, err
}
return pair, nil
}

// createTestCertificateByIssuer generates a certificate and private key pair, optionally signed by an issuer.
func createTestCertificateByIssuer(name string, issuer *certKeyPair) (*certKeyPair, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
Expand Down
Loading

0 comments on commit 42ce0bf

Please sign in to comment.