Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add certificate extract command for conversion between P12, PEM, and DER #589

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
273 changes: 251 additions & 22 deletions command/certificate/format.go
Original file line number Diff line number Diff line change
@@ -6,30 +6,43 @@ import (
"encoding/pem"
"os"

"go.step.sm/crypto/pemutil"

"github.com/pkg/errors"
"github.com/smallstep/cli/flags"
"github.com/smallstep/cli/ui"
"github.com/smallstep/cli/utils"
"github.com/urfave/cli"
"go.step.sm/cli-utils/command"
"go.step.sm/cli-utils/errs"

"software.sslmate.com/src/go-pkcs12"
)

func formatCommand() cli.Command {
return cli.Command{
Name: "format",
Action: command.ActionFunc(formatAction),
Usage: `reformat certificate`,
UsageText: `**step certificate format** <crt-file> [**--out**=<file>]`,
Name: "format",
Action: command.ActionFunc(formatAction),
Usage: `reformat certificate`,
UsageText: `**step certificate format** <crt-file> [**--crt**=<file>] [**--key**=<file>]
[**--ca**=<file>] [**--out**=<file>] [**--format**=<format>]`,
Description: `**step certificate format** prints the certificate or CSR in a different format.
Only 2 formats are currently supported; PEM and ASN.1 DER. This tool will convert
a certificate or CSR in one format to the other.
If either PEM or ASN.1 DER is provided as a positional argument, this command
will convert a certificate or CSR in one format to the other.
If PFX / PKCS12 file is provided as a positional argument, and the format is
specified as "pem"/"der", this command extracts a certificate and private key
from the input.
If either PEM or ASN.1 DER is provided in "--crt" | "--key" | "--ca", and the
format is specified as "p12", this command creates a PFX / PKCS12 file from the input .
## POSITIONAL ARGUMENTS
<crt-file>
: Path to a certificate or CSR file.
: Path to a certificate, CSR, or .p12 file.
<crt-file>
## EXIT CODES
@@ -51,12 +64,72 @@ Convert PEM format to DER and write to disk:
'''
$ step certificate format foo.pem --out foo.der
'''
Convert a .p12 file to a certificate and private key:
'''
$ step certificate format foo.p12 --crt foo.crt --key foo.key --format pem
'''
Convert a .p12 file to a certificate, private key and intermediate certificates:
'''
$ step certificate format foo.p12 --crt foo.crt --key foo.key --ca intermediate.crt --format pem
'''
Comment on lines +68 to +78
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like if the flag --format is not passed it will write PEM files, as it should be. We should show this in one of these two examples, explaining that is the default behavior.

Convert a certificate and private key to a .p12 file:
'''
$ step certificate format foo.crt --crt foo.p12 --key foo.key --format p12
'''
Convert a certificate, a private key, and intermediate certificates(s) to a .p12 file:
'''
$ step certificate format foo.crt --crt foo.p12 --key foo.key \
--ca intermediate-1.crt --ca intermediate-2 --format p12
'''
`,
Flags: []cli.Flag{
cli.StringFlag{
Name: "out",
Usage: `Path to write the reformatted result.`,
Name: "format",
Usage: `The desired output <format> for the input. The default behavior is to
convert between DER and PEM format. Acceptable formats are 'pem', 'der', and 'p12'.`,
},
cli.StringFlag{
Name: "crt",
Usage: `The path to a certificate <file>. If --format is 'p12' then this flag
must be a PEM or DER encoded certificate. If the positional argument is a P12
encoded file then this flag contains the name for the PEM or DER encoded leaf
certificate extracted from the p12 file.`,
},
cli.StringFlag{
Name: "key",
Usage: `The path to a key <file>. If --format is 'p12' then this flag
must be a PEM or DER encoded private key. If the positional argument is a P12
encoded file then this flag contains the name for the PEM or DER encoded private
key extracted from the p12 file.`,
},
cli.StringSliceFlag{
Name: "ca",
Usage: `The path to a root or intermediate certificate <file>. If --format is 'p12'
then this flag can be used to submit one or more CA files encoded as PEM or DER.
Additional CA certificates can be added by using the --ca flag multiple times.
If the positional argument is a p12 encoded file then this flag contains the
name for the PEM or DER encoded certificate chain extracted from the p12 file.`,
},
cli.StringFlag{
Name: "out",
Usage: `The <file> to write the reformatted result. Only use this flag
for conversions between PEM and DER. Conversions to P12 should use --crt, --key,
and --ca.`,
},
cli.StringFlag{
Name: "password-file",
Usage: `The path to the <file> containing the password to encrypt/decrypt the .p12 file.`,
},
flags.NoPassword,
flags.Insecure,
flags.Force,
},
}
@@ -67,15 +140,97 @@ func formatAction(ctx *cli.Context) error {
return err
}

sourceFile := ctx.Args().First()
format := ctx.String("format")
crtFile := ctx.String("crt")
keyFile := ctx.String("key")
caFiles := ctx.StringSlice("ca")
out := ctx.String("out")
passwordFile := ctx.String("password-file")
noPassword := ctx.Bool("no-password")
insecure := ctx.Bool("insecure")

if out != "" {
if crtFile != "" {
return errs.IncompatibleFlagWithFlag(ctx, "out", "crt")
}
if keyFile != "" {
return errs.IncompatibleFlagWithFlag(ctx, "out", "key")
}
if format != "" {
return errs.IncompatibleFlagWithFlag(ctx, "out", "format")
}
}

if passwordFile != "" && noPassword {
return errs.IncompatibleFlagWithFlag(ctx, "no-password", "password-file")
}

var (
out = ctx.String("out")
ob []byte
err error
pass = ""
)
if passwordFile != "" {
pass, err = utils.ReadStringPasswordFromFile(passwordFile)
if err != nil {
return errs.FileError(err, passwordFile)
}
}

var crtFile string
if ctx.NArg() == 1 {
crtFile = ctx.Args().First()
} else {
if sourceFile != "" {
srcBytes, err := os.ReadFile(sourceFile)
if err != nil {
return errs.FileError(err, sourceFile)
}

// First check if P12 input.
if keyFrom, crtFrom, caFrom, err := pkcs12.DecodeChain(srcBytes, pass); err == nil {
if format == "p12" {
return errors.Errorf("invalid flag --format with value 'p12'; cannot from P12 format to P12 format")
}
if len(caFrom) > 1 {
return errors.Errorf("flag --ca cannot be used multiple times when converting from P12 format")
}
caFile := ""
if len(caFiles) == 1 {
caFile = caFiles[0]
}
if err := write(crtFile, format, crtFrom); err != nil {
return err
}

if err := writeCerts(caFile, format, caFrom); err != nil {
return err
}

if err := write(keyFile, format, keyFrom); err != nil {
return err
}
}

// Now we know input is not P12 format. Check if we're converting to P12.
if format == "p12" {
if noPassword && !insecure {
return errs.RequiredInsecureFlag(ctx, "no-password")
}
return ToP12(out, crtFile, keyFile, caFiles, passwordFile, noPassword, insecure)
}

// Otherwise interconvert between PEM and DER.
return interconvertPemAndDer(sourceFile, out)
}

// If format is PEM or DER (not P12) then an input certificate file is required.
if format != "p12" {
return errors.Errorf("flag --format with value '%s' requires a certificate file as positional argument", format)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commands like these will stop working:

$ cat cert.pem | step certificate format
... der data ...
$ cat cert.der | step certificate format
... pem data ...

}
return ToP12(out, crtFile, keyFile, caFiles, passwordFile, noPassword, insecure)
}

func interconvertPemAndDer(crtFile, out string) error {
var ob []byte

if crtFile == "" {
crtFile = "-"
}

@@ -116,7 +271,7 @@ func formatAction(ctx *cli.Context) error {
}
}
if err := utils.WriteFile(out, ob, mode); err != nil {
return err
return errs.FileError(err, out)
}
ui.Printf("Your certificate has been saved in %s\n", out)
}
@@ -133,21 +288,95 @@ func decodeCertificatePem(b []byte) ([]byte, error) {
}
switch block.Type {
case "CERTIFICATE":
crt, err := x509.ParseCertificate(block.Bytes)
if err != nil {
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
return nil, errors.Wrap(err, "error parsing certificate")
}
return crt.Raw, nil
return block.Bytes, nil
case "CERTIFICATE REQUEST":
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
if _, err := x509.ParseCertificateRequest(block.Bytes); err != nil {
return nil, errors.Wrap(err, "error parsing certificate request")
}
return csr.Raw, nil
return block.Bytes, nil
case "RSA PRIVATE KEY":
if _, err := x509.ParsePKCS1PrivateKey(block.Bytes); err != nil {
return nil, errors.Wrap(err, "error parsing RSA private key")
}
return block.Bytes, nil
case "EC PRIVATE KEY":
if _, err := x509.ParseECPrivateKey(block.Bytes); err != nil {
return nil, errors.Wrap(err, "error parsing EC private key")
}
return block.Bytes, nil
case "PRIVATE KEY":
if _, err := x509.ParsePKCS8PrivateKey(block.Bytes); err != nil {
return nil, errors.Wrap(err, "error parsing private key")
}

return block.Bytes, nil
default:
continue
}
}

return nil, errors.Errorf("error decoding certificate: invalid PEM block")
}

func writeCerts(filename, format string, certs []*x509.Certificate) error {
if len(certs) > 1 && format == "der" {
return errors.Errorf("der format does not support a certificate bundle")
}
var data []byte
for _, cert := range certs {
b, err := toByte(cert, format)
if err != nil {
return err
}
data = append(data, b...)
}
if err := maybeWrite(filename, data); err != nil {
return err
}
return nil
}

func write(filename, format string, in interface{}) error {
b, err := toByte(in, format)
if err != nil {
return err
}
if err := maybeWrite(filename, b); err != nil {
return err
}
return nil
}

func maybeWrite(filename string, out []byte) error {
if filename == "" {
os.Stdout.Write(out)
} else {
if err := utils.WriteFile(filename, out, 0600); err != nil {
return errs.FileError(err, filename)
}
}
return nil
}

func toByte(in interface{}, format string) ([]byte, error) {
pemblk, err := pemutil.Serialize(in)
if err != nil {
return nil, err
}
pemByte := pem.EncodeToMemory(pemblk)
switch format {
case "der":
derByte, err := decodeCertificatePem(pemByte)
if err != nil {
return nil, err
}
return derByte, nil
case "pem", "":
return pemByte, nil
default:
return nil, errors.Errorf("unsupported format: %s", format)
}
}
38 changes: 28 additions & 10 deletions command/certificate/p12.go
Original file line number Diff line number Diff line change
@@ -3,6 +3,7 @@ package certificate
import (
"crypto/rand"
"crypto/x509"
"os"

"github.com/pkg/errors"
"github.com/smallstep/cli/crypto/pemutil"
@@ -85,6 +86,10 @@ func p12Action(ctx *cli.Context) error {
caFiles := ctx.StringSlice("ca")
hasKeyAndCert := crtFile != "" && keyFile != ""

passwordFile := ctx.String("password-file")
noPassword := ctx.Bool("no-password")
insecure := ctx.Bool("insecure")

// If either key or cert are provided, both must be provided
if !hasKeyAndCert && (crtFile != "" || keyFile != "") {
return errs.MissingArguments(ctx, "key_file")
@@ -97,13 +102,20 @@ func p12Action(ctx *cli.Context) error {

// Validate flags
switch {
case ctx.String("password-file") != "" && ctx.Bool("no-password"):
case passwordFile != "" && noPassword:
return errs.IncompatibleFlagWithFlag(ctx, "no-password", "password-file")
case ctx.Bool("no-password") && !ctx.Bool("insecure"):
case noPassword && !insecure:
return errs.RequiredInsecureFlag(ctx, "no-password")
}

x509CAs := []*x509.Certificate{}
if err := ToP12(p12File, crtFile, keyFile, caFiles, passwordFile, noPassword, insecure); err != nil {
return err
}
return nil
}

func ToP12(p12File, crtFile, keyFile string, caFiles []string, passwordFile string, noPassword, insecure bool) error {
var x509CAs []*x509.Certificate
for _, caFile := range caFiles {
x509Bundle, err := pemutil.ReadCertificateBundle(caFile)
if err != nil {
@@ -114,8 +126,8 @@ func p12Action(ctx *cli.Context) error {

var err error
var password string
if !ctx.Bool("no-password") {
if passwordFile := ctx.String("password-file"); passwordFile != "" {
if !noPassword {
if passwordFile != "" {
password, err = utils.ReadStringPasswordFromFile(passwordFile)
if err != nil {
return err
@@ -132,7 +144,7 @@ func p12Action(ctx *cli.Context) error {
}

var pkcs12Data []byte
if hasKeyAndCert {
if crtFile != "" && keyFile != "" {
// If we have a key and certificate, we're making an identity store
x509CertBundle, err := pemutil.ReadCertificateBundle(crtFile)
if err != nil {
@@ -146,7 +158,7 @@ func p12Action(ctx *cli.Context) error {

// The first certificate in the bundle will be our server cert
x509Cert := x509CertBundle[0]
// Any remaning certs will be intermediates for the server
// Any remaining certs will be intermediates for the server
x509CAs = append(x509CAs, x509CertBundle[1:]...)

pkcs12Data, err = pkcs12.Encode(rand.Reader, key, x509Cert, x509CAs, password)
@@ -161,10 +173,16 @@ func p12Action(ctx *cli.Context) error {
}
}

if err := utils.WriteFile(p12File, pkcs12Data, 0600); err != nil {
return err
if p12File != "" {
if err := utils.WriteFile(p12File, pkcs12Data, 0600); err != nil {
return err
}
ui.Printf("Your .p12 bundle has been saved as %s.\n", p12File)
} else {
if _, err := os.Stdout.Write(pkcs12Data); err != nil {
return err
}
}

ui.Printf("Your .p12 bundle has been saved as %s.\n", p12File)
return nil
}
183 changes: 183 additions & 0 deletions integration/certificate_format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
//go:build integration

package integration

import (
"fmt"
"testing"

"github.com/smallstep/assert"
"github.com/smallstep/cli/crypto/pemutil"
"github.com/smallstep/cli/utils"
)

func TestCertificateFormat(t *testing.T) {
setup()
t.Run("validate cert and key extraction from p12", func(t *testing.T) {
_, err := NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate format %s", temp("foo.p12"))).
setFlag("crt", temp("foo_out0.crt")).
setFlag("key", temp("foo_out0.key")).
setFlag("ca", temp("intermediate-ca_out0.crt")).
setFlag("format", "pem").
setFlag("no-password", "").
run()
assert.Nil(t, err)

foo_crt, _ := pemutil.ReadCertificate(temp("foo.crt"))
foo_crt_out, _ := pemutil.ReadCertificate(temp("foo_out0.crt"))
assert.Equals(t, foo_crt, foo_crt_out)

foo_key, _ := utils.ReadFile(temp("foo.key"))
foo_out_key, _ := utils.ReadFile(temp("foo_out0.key"))
assert.Equals(t, foo_key, foo_out_key)

foo_ca, _ := pemutil.ReadCertificate(temp("intermediate-ca_out0.crt"))
foo_ca_out, _ := pemutil.ReadCertificate(temp("intermediate-ca_out0.crt"))
assert.Equals(t, foo_ca, foo_ca_out)
})

t.Run("validate cert and key packaging to p12", func(t *testing.T) {
_, err := NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate format %s", temp("foo.crt"))).
setFlag("crt", temp("foo_format.p12")).
setFlag("key", temp("foo.key")).
setFlag("ca", temp("intermediate-ca.crt")).
setFlag("format", "p12").
setFlag("no-password", "").
setFlag("insecure", "").
run()
assert.Nil(t, err)

_, err = NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate format %s", temp("foo_format.p12"))).
setFlag("crt", temp("foo_out1.crt")).
setFlag("key", temp("foo_out1.key")).
setFlag("ca", temp("intermediate-ca_out1.crt")).
setFlag("format", "pem").
setFlag("no-password", "").
run()

assert.Nil(t, err)

foo_crt, _ := pemutil.ReadCertificate(temp("foo.crt"))
foo_crt_out, _ := pemutil.ReadCertificate(temp("foo_out1.crt"))
assert.Equals(t, foo_crt, foo_crt_out)

foo_key, _ := utils.ReadFile(temp("foo.key"))
foo_out_key, _ := utils.ReadFile(temp("foo_out1.key"))
assert.Equals(t, foo_key, foo_out_key)

foo_ca, _ := pemutil.ReadCertificate(temp("intermediate-ca.crt"))
foo_ca_out, _ := pemutil.ReadCertificate(temp("intermediate-ca_out1.crt"))
assert.Equals(t, foo_ca, foo_ca_out)
})

t.Run("validate stdout output", func(t *testing.T) {
output, err := NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate format %s", temp("foo.p12"))).
setFlag("no-password", "").
setFlag("key", temp("temp.key")).
setFlag("ca", temp("temp.crt")).
setFlag("format", "pem").
run()
assert.Nil(t, err)

foo_crt, _ := pemutil.ReadCertificate(temp("foo.crt"))
foo_crt_out, _ := pemutil.Parse([]byte(output.stdout))
assert.Equals(t, foo_crt, foo_crt_out)
})

t.Run("compare der format", func(t *testing.T) {
_, err := NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate format")).
setArguments(temp("foo.crt")).
setFlag("out", temp("foo.der")).
run()
assert.Nil(t, err)


_, err = NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate format %s", temp("foo.p12"))).
setFlag("no-password", "").
setFlag("format", "der").
setFlag("crt", temp("foo_cmp.der")).
run()

assert.Nil(t, err)

foo_crt, _ := pemutil.ReadCertificate(temp("foo.der"))
foo_crt_out, _ := pemutil.ReadCertificate(temp("foo_cmp.der"))
assert.Equals(t, foo_crt, foo_crt_out)
})

t.Run("validate interconversion between PEM and DER", func(t *testing.T) {
_, err := NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate format")).
setArguments(temp("foo.crt")).
setFlag("out", temp("foo_inter.der")).
run()
assert.Nil(t, err)

_, err = NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate format")).
setArguments(temp("foo_inter.der")).
setFlag("out", temp("foo_inter.crt")).
run()
assert.Nil(t, err)

assert.Nil(t, err)

foo_crt, _ := pemutil.ReadCertificate(temp("foo.crt"))
foo_crt_out, _ := pemutil.ReadCertificate(temp("foo_inter.crt"))
assert.Equals(t, foo_crt, foo_crt_out)
})

t.Run("assert incompatible flag", func(t *testing.T) {
output, _ := NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate format %s", temp("foo.p12"))).
setFlag("out", temp("some")).
setFlag("key", temp("some")).
run()
assert.Equals(t, "flag '--out' is incompatible with '--key'\n", output.stderr)
})

}

func setup() {
NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate create root-ca %s %s", temp("root-ca.crt"), temp("root-ca.key"))).
setFlag("profile", "root-ca").
setFlag("no-password", "").
setFlag("insecure", "").
run()

NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate create intermediate-ca %s %s", temp("intermediate-ca.crt"), temp("intermediate-ca.key"))).
setFlag("profile", "intermediate-ca").
setFlag("ca", temp("root-ca.crt")).
setFlag("ca-key", temp("root-ca.key")).
setFlag("no-password", "").
setFlag("insecure", "").
run()

NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate create foo %s %s", temp("foo.crt"), temp("foo.key"))).
setFlag("profile", "leaf").
setFlag("ca", temp("intermediate-ca.crt")).
setFlag("ca-key", temp("intermediate-ca.key")).
setFlag("no-password", "").
setFlag("insecure", "").
run()

NewCLICommand().
setCommand(fmt.Sprintf("../bin/step certificate p12 %s %s %s", temp("foo.p12"), temp("foo.crt"), temp("foo.key"))).
setFlag("ca", temp("intermediate-ca.crt")).
setFlag("no-password", "").
setFlag("insecure", "").
run()
}

func temp(filename string) string {
return fmt.Sprintf("%s/%s", TempDirectory, filename)
}