Repository: go-acme/lego (language: go)

Issue:
Add a --pfx, and --pfx.pass option to generate a PKCS#12 (.pfx) file.
- [X] Yes, I've searched similar issues on GitHub and didn't find any.

I create the pull request #1387 that adds the ability to create PKCS#12 files on certificate generation or renew.

I found the issue #184 that talks about the problem I want to solve that is generate .pfx files for use on Windows.



Discussion / hints:

Fix patch:
diff --git a/cmd/certs_storage.go b/cmd/certs_storage.go
index d61f265d99..bb62cb46f1 100644
--- a/cmd/certs_storage.go
+++ b/cmd/certs_storage.go
@@ -2,8 +2,12 @@ package cmd
 
 import (
 	"bytes"
+	"crypto"
+	"crypto/rand"
 	"crypto/x509"
 	"encoding/json"
+	"encoding/pem"
+	"fmt"
 	"os"
 	"path/filepath"
 	"strconv"
@@ -15,6 +19,7 @@ import (
 	"github.com/go-acme/lego/v4/log"
 	"github.com/urfave/cli"
 	"golang.org/x/net/idna"
+	"software.sslmate.com/src/go-pkcs12"
 )
 
 const (
@@ -40,6 +45,8 @@ type CertificatesStorage struct {
 	rootPath    string
 	archivePath string
 	pem         bool
+	pfx         bool
+	pfxPassword string
 	filename    string // Deprecated
 }
 
@@ -49,6 +56,8 @@ func NewCertificatesStorage(ctx *cli.Context) *CertificatesStorage {
 		rootPath:    filepath.Join(ctx.GlobalString("path"), baseCertificatesFolderName),
 		archivePath: filepath.Join(ctx.GlobalString("path"), baseArchivesFolderName),
 		pem:         ctx.GlobalBool("pem"),
+		pfx:         ctx.GlobalBool("pfx"),
+		pfxPassword: ctx.GlobalString("pfx.pass"),
 		filename:    ctx.GlobalString("filename"),
 	}
 }
@@ -88,22 +97,15 @@ func (s *CertificatesStorage) SaveResource(certRes *certificate.Resource) {
 		}
 	}
 
+	// if we were given a CSR, we don't know the private key
 	if certRes.PrivateKey != nil {
-		// if we were given a CSR, we don't know the private key
-		err = s.WriteFile(domain, ".key", certRes.PrivateKey)
+		err = s.WriteCertificateFiles(domain, certRes)
 		if err != nil {
 			log.Fatalf("Unable to save PrivateKey for domain %s\n\t%v", domain, err)
 		}
-
-		if s.pem {
-			err = s.WriteFile(domain, ".pem", bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil))
-			if err != nil {
-				log.Fatalf("Unable to save Certificate and PrivateKey in .pem for domain %s\n\t%v", domain, err)
-			}
-		}
-	} else if s.pem {
-		// we don't have the private key; can't write the .pem file
-		log.Fatalf("Unable to save pem without private key for domain %s\n\t%v; are you using a CSR?", domain, err)
+	} else if s.pem || s.pfx {
+		// we don't have the private key; can't write the .pem or .pfx file
+		log.Fatalf("Unable to save PEM or PFX without private key for domain %s. Are you using a CSR?", domain)
 	}
 
 	jsonBytes, err := json.MarshalIndent(certRes, "", "\t")
@@ -174,6 +176,81 @@ func (s *CertificatesStorage) WriteFile(domain, extension string, data []byte) e
 	return os.WriteFile(filePath, data, filePerm)
 }
 
+func (s *CertificatesStorage) WriteCertificateFiles(domain string, certRes *certificate.Resource) error {
+	err := s.WriteFile(domain, ".key", certRes.PrivateKey)
+	if err != nil {
+		return fmt.Errorf("unable to save key file: %w", err)
+	}
+
+	if s.pem {
+		err = s.WriteFile(domain, ".pem", bytes.Join([][]byte{certRes.Certificate, certRes.PrivateKey}, nil))
+		if err != nil {
+			return fmt.Errorf("unable to save PEM file: %w", err)
+		}
+	}
+
+	if s.pfx {
+		err = s.WritePFXFile(domain, certRes)
+		if err != nil {
+			return fmt.Errorf("unable to save PFX file: %w", err)
+		}
+	}
+
+	return nil
+}
+
+func (s *CertificatesStorage) WritePFXFile(domain string, certRes *certificate.Resource) error {
+	certPemBlock, _ := pem.Decode(certRes.Certificate)
+	if certPemBlock == nil {
+		return fmt.Errorf("unable to parse Certificate for domain %s", domain)
+	}
+
+	cert, err := x509.ParseCertificate(certPemBlock.Bytes)
+	if err != nil {
+		return fmt.Errorf("unable to load Certificate for domain %s: %w", domain, err)
+	}
+
+	issuerCertPemBlock, _ := pem.Decode(certRes.IssuerCertificate)
+	if issuerCertPemBlock == nil {
+		return fmt.Errorf("unable to parse Issuer Certificate for domain %s", domain)
+	}
+
+	issuerCert, err := x509.ParseCertificate(issuerCertPemBlock.Bytes)
+	if err != nil {
+		return fmt.Errorf("unable to load Issuer Certificate for domain %s: %w", domain, err)
+	}
+
+	keyPemBlock, _ := pem.Decode(certRes.PrivateKey)
+	if keyPemBlock == nil {
+		return fmt.Errorf("unable to parse PrivateKey for domain %s", domain)
+	}
+
+	var privateKey crypto.Signer
+	var keyErr error
+
+	switch keyPemBlock.Type {
+	case "RSA PRIVATE KEY":
+		privateKey, keyErr = x509.ParsePKCS1PrivateKey(keyPemBlock.Bytes)
+		if keyErr != nil {
+			return fmt.Errorf("unable to load RSA PrivateKey for domain %s: %w", domain, keyErr)
+		}
+	case "EC PRIVATE KEY":
+		privateKey, keyErr = x509.ParseECPrivateKey(keyPemBlock.Bytes)
+		if keyErr != nil {
+			return fmt.Errorf("unable to load EC PrivateKey for domain %s: %w", domain, keyErr)
+		}
+	default:
+		return fmt.Errorf("unsupported PrivateKey type '%s' for domain %s", keyPemBlock.Type, domain)
+	}
+
+	pfxBytes, err := pkcs12.Encode(rand.Reader, privateKey, cert, []*x509.Certificate{issuerCert}, s.pfxPassword)
+	if err != nil {
+		return fmt.Errorf("unable to encode PFX data for domain %s: %w", domain, err)
+	}
+
+	return s.WriteFile(domain, ".pfx", pfxBytes)
+}
+
 func (s *CertificatesStorage) MoveToArchive(domain string) error {
 	matches, err := filepath.Glob(filepath.Join(s.rootPath, sanitizedDomain(domain)+".*"))
 	if err != nil {
diff --git a/cmd/flags.go b/cmd/flags.go
index 8307b2c9f3..dcc900185e 100644
--- a/cmd/flags.go
+++ b/cmd/flags.go
@@ -3,6 +3,7 @@ package cmd
 import (
 	"github.com/go-acme/lego/v4/lego"
 	"github.com/urfave/cli"
+	pkcs12 "software.sslmate.com/src/go-pkcs12"
 )
 
 func CreateFlags(defaultPath string) []cli.Flag {
@@ -111,6 +112,15 @@ func CreateFlags(defaultPath string) []cli.Flag {
 			Name:  "pem",
 			Usage: "Generate a .pem file by concatenating the .key and .crt files together.",
 		},
+		cli.BoolFlag{
+			Name:  "pfx",
+			Usage: "Generate a .pfx (PKCS#12) file by with the .key and .crt and issuer .crt files together.",
+		},
+		cli.StringFlag{
+			Name:  "pfx.pass",
+			Usage: "The password used to encrypt the .pfx (PCKS#12) file.",
+			Value: pkcs12.DefaultPassword,
+		},
 		cli.IntFlag{
 			Name:  "cert.timeout",
 			Usage: "Set the certificate timeout value to a specific value in seconds. Only used when obtaining certificates.",
diff --git a/docs/content/usage/cli/_index.md b/docs/content/usage/cli/_index.md
index 14f2085953..c32d0755a7 100644
--- a/docs/content/usage/cli/_index.md
+++ b/docs/content/usage/cli/_index.md
@@ -50,6 +50,8 @@ GLOBAL OPTIONS:
    --http-timeout value         Set the HTTP timeout value to a specific value in seconds. (default: 0)
    --dns-timeout value          Set the DNS timeout value to a specific value in seconds. Used only when performing authoritative name servers queries. (default: 10)
    --pem                        Generate a .pem file by concatenating the .key and .crt files together.
+   --pfx                        Generate a .pfx (PKCS#12) file by with the .key and .crt and issuer .crt files together.
+   --pfx.pass                   The password used to encrypt the .pfx (PCKS#12) file (default: changeit).
    --cert.timeout value         Set the certificate timeout value to a specific value in seconds. Only used when obtaining certificates. (default: 30)
    --help, -h                   show help
    --version, -v                print the version
diff --git a/go.mod b/go.mod
index 651b01b5a4..334ac1bdab 100644
--- a/go.mod
+++ b/go.mod
@@ -56,11 +56,12 @@ require (
 	github.com/vinyldns/go-vinyldns v0.9.16
 	github.com/vultr/govultr/v2 v2.7.1
 	golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e
-	golang.org/x/net v0.0.0-20210614182718-04defd469f4e
+	golang.org/x/net v0.0.0-20210510120150-4163338589ed
 	golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
 	golang.org/x/time v0.0.0-20210611083556-38a9dc6acbc6
 	google.golang.org/api v0.20.0
 	gopkg.in/ns1/ns1-go.v2 v2.6.2
 	gopkg.in/square/go-jose.v2 v2.6.0
 	gopkg.in/yaml.v2 v2.4.0
+	software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78
 )
diff --git a/go.sum b/go.sum
index 2e2d569f65..3d15eb9328 100644
--- a/go.sum
+++ b/go.sum
@@ -515,6 +515,7 @@ golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPh
 golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
+golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
 golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
 golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e h1:gsTQYXdTw2Gq7RBsWvlQ91b+aEQ6bXFUngBGuR8sPpI=
 golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -579,9 +580,8 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY
 golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
+golang.org/x/net v0.0.0-20210510120150-4163338589ed h1:p9UgmWI9wKpfYmgaV/IZKGdXc5qEK45tDwwwDyjS26I=
 golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20210614182718-04defd469f4e h1:XpT3nA5TvE525Ne3hInMh6+GETgn27Zfm9dxsThnX2Q=
-golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -799,3 +799,5 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
+software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78 h1:SqYE5+A2qvRhErbsXFfUEUmpWEKxxRSMgGLkvRAFOV4=
+software.sslmate.com/src/go-pkcs12 v0.0.0-20210415151418-c5206de65a78/go.mod h1:B7Wf0Ya4DHF9Yw+qfZuJijQYkWicqDa+79Ytmmq3Kjg=


Test patch: