diff --git a/command/certificate/format.go b/command/certificate/format.go index 313dab967..384ea9cfb 100644 --- a/command/certificate/format.go +++ b/command/certificate/format.go @@ -6,6 +6,8 @@ import ( "encoding/pem" "os" + "go.step.sm/crypto/pemutil" + "github.com/pkg/errors" "github.com/smallstep/cli/flags" "github.com/smallstep/cli/ui" @@ -13,23 +15,34 @@ import ( "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** [**--out**=]`, + Name: "format", + Action: command.ActionFunc(formatAction), + Usage: `reformat certificate`, + UsageText: `**step certificate format** [**--crt**=] [**--key**=] +[**--ca**=] [**--out**=] [**--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 -: Path to a certificate or CSR file. +: Path to a certificate, CSR, or .p12 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 +''' + +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 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 . 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 . 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 . 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 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 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) + } + 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,17 +288,31 @@ 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 } @@ -151,3 +320,63 @@ func decodeCertificatePem(b []byte) ([]byte, error) { 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) + } +} diff --git a/command/certificate/p12.go b/command/certificate/p12.go index cf5c4123d..7ce3c42d7 100644 --- a/command/certificate/p12.go +++ b/command/certificate/p12.go @@ -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 } diff --git a/integration/certificate_format_test.go b/integration/certificate_format_test.go new file mode 100644 index 000000000..2a6de2f37 --- /dev/null +++ b/integration/certificate_format_test.go @@ -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) +}