From 674d56d9c7cccd3544503d1ee839b6b064eefb91 Mon Sep 17 00:00:00 2001 From: Kit Haines Date: Fri, 10 Feb 2023 15:27:36 -0500 Subject: [PATCH] Vault 11799 Vault CLI Re-Issue (Templating based on existing certificate) (#18499) * The verify-sign command in it's cleanest existing form. * Working state * Updates to proper verification syntax Co-authored-by: 'Alex Scheel' * make fmt * Base functionality. * make fmt; changelog * pki issue command. * Make fmt. Changelog. * Error Handling Is Almost A Tutorial * Issue and ReIssue are Almost the Same Command * Make Fmt + Changelog. * Make some of the tests go. * make fmt * Merge fix (take 2) * Fix existing support, add support for use_pss, max_path_length, not_after, permitted_dns_domains and skid * Good Test which Fails * Test-correction. * Fix update to key_type key_bits; allow "," in OU or similar * More specific includeCNinSANs * Add tests around trying to use_pss on an ec key. * GoDoc Test Paragraph thing. --------- Co-authored-by: 'Alex Scheel' --- changelog/18499.txt | 3 + command/commands.go | 5 + command/pki_issue_intermediate.go | 28 ++- command/pki_reissue_intermediate.go | 297 +++++++++++++++++++++++ command/pki_reissue_intermediate_test.go | 195 +++++++++++++++ 5 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 changelog/18499.txt create mode 100644 command/pki_reissue_intermediate.go create mode 100644 command/pki_reissue_intermediate_test.go diff --git a/changelog/18499.txt b/changelog/18499.txt new file mode 100644 index 000000000..b329ed0db --- /dev/null +++ b/changelog/18499.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli/pki: Added "Reissue" command which allows extracting fields from an existing certificate to create a new certificate. +``` \ No newline at end of file diff --git a/command/commands.go b/command/commands.go index 2938ee1bb..f441c3ee1 100644 --- a/command/commands.go +++ b/command/commands.go @@ -555,6 +555,11 @@ func initCommands(ui, serverCmdUi cli.Ui, runOpts *RunOptions) map[string]cli.Co BaseCommand: getBaseCommand(), }, nil }, + "pki reissue": func() (cli.Command, error) { + return &PKIReIssueCACommand{ + BaseCommand: getBaseCommand(), + }, nil + }, "pki verify-sign": func() (cli.Command, error) { return &PKIVerifySignCommand{ BaseCommand: getBaseCommand(), diff --git a/command/pki_issue_intermediate.go b/command/pki_issue_intermediate.go index 90bb04fc9..b2ca13330 100644 --- a/command/pki_issue_intermediate.go +++ b/command/pki_issue_intermediate.go @@ -89,6 +89,14 @@ func (c *PKIIssueCACommand) Run(args []string) int { return 1 } + parentMountIssuer := sanitizePath(args[0]) // /pki/issuer/default + + intermediateMount := sanitizePath(args[1]) + + return pkiIssue(c.BaseCommand, parentMountIssuer, intermediateMount, c.flagNewIssuerName, c.flagKeyStorageSource, data) +} + +func pkiIssue(c *BaseCommand, parentMountIssuer string, intermediateMount string, flagNewIssuerName string, flagKeyStorageSource string, data map[string]interface{}) int { // Check We Have a Client client, err := c.Client() if err != nil { @@ -97,8 +105,6 @@ func (c *PKIIssueCACommand) Run(args []string) int { } // Sanity Check the Parent Issuer - parentMountIssuer := sanitizePath(args[0]) // /pki/issuer/default - _, parentIssuerName := paths.Split(parentMountIssuer) if !strings.Contains(parentMountIssuer, "/issuer/") { c.UI.Error(fmt.Sprintf("Parent Issuer %v is Not a PKI Issuer Path of the format /mount/issuer/issuer-ref", parentMountIssuer)) } @@ -108,16 +114,15 @@ func (c *PKIIssueCACommand) Run(args []string) int { } // Set-up Failure State (Immediately Before First Write Call) - intermediateMount := sanitizePath(args[1]) failureState := inCaseOfFailure{ intermediateMount: intermediateMount, parentMount: strings.Split(parentMountIssuer, "/issuer/")[0], parentIssuer: parentMountIssuer, - newName: c.flagNewIssuerName, + newName: flagNewIssuerName, } // Generate Certificate Signing Request - csrResp, err := client.Logical().Write(intermediateMount+"/intermediate/generate/"+c.flagKeyStorageSource, data) + csrResp, err := client.Logical().Write(intermediateMount+"/intermediate/generate/"+flagKeyStorageSource, data) if err != nil { if strings.Contains(err.Error(), "no handler for route") { // Mount Given Does Not Exist c.UI.Error(fmt.Sprintf("Given Intermediate Mount %v Does Not Exist: %v", intermediateMount, err)) @@ -129,21 +134,21 @@ func (c *PKIIssueCACommand) Run(args []string) int { return 1 } // Parse CSR Response, Also Verifies that this is a PKI Mount - // (eg. calling the above call on cubbyhole/ won't return an error response) + // (e.g. calling the above call on cubbyhole/ won't return an error response) csrPemRaw, present := csrResp.Data["csr"] if !present { c.UI.Error(fmt.Sprintf("Failed to Generate Intermediate CSR on %v, got response: %v", intermediateMount, csrResp)) return 1 } keyIdRaw, present := csrResp.Data["key_id"] - if !present && c.flagKeyStorageSource == "internal" { + if !present && flagKeyStorageSource == "internal" { c.UI.Error(fmt.Sprintf("Failed to Generate Key on %v, got response: %v", intermediateMount, csrResp)) return 1 } // If that all Parses, then we've successfully generated a CSR! Save It (and the Key-ID) failureState.csrGenerated = true - if c.flagKeyStorageSource == "internal" { + if flagKeyStorageSource == "internal" { failureState.createdKeyId = keyIdRaw.(string) } csr := csrPemRaw.(string) @@ -171,7 +176,7 @@ func (c *PKIIssueCACommand) Run(args []string) int { // Next Import Certificate certificate := rootResp.Data["certificate"].(string) - issuerId, err := importIssuerWithName(client, intermediateMount, certificate, c.flagNewIssuerName) + issuerId, err := importIssuerWithName(client, intermediateMount, certificate, flagNewIssuerName) failureState.certIssuerId = issuerId if err != nil { if strings.Contains(err.Error(), "error naming issuer") { @@ -189,6 +194,7 @@ func (c *PKIIssueCACommand) Run(args []string) int { // Then Import Issuing Certificate issuingCa := rootResp.Data["issuing_ca"].(string) + _, parentIssuerName := paths.Split(parentMountIssuer) _, err = importIssuerWithName(client, intermediateMount, issuingCa, parentIssuerName) if err != nil { if strings.Contains(err.Error(), "error naming issuer") { @@ -215,12 +221,12 @@ func (c *PKIIssueCACommand) Run(args []string) int { failureState.caChainImported = true // Finally we read our newly issued certificate in order to tell our caller about it - c.readAndOutputNewCertificate(client, intermediateMount, issuerId) + readAndOutputNewCertificate(client, intermediateMount, issuerId, c) return 0 } -func (c *PKIIssueCACommand) readAndOutputNewCertificate(client *api.Client, intermediateMount string, issuerId string) { +func readAndOutputNewCertificate(client *api.Client, intermediateMount string, issuerId string, c *BaseCommand) { resp, err := client.Logical().Read(sanitizePath(intermediateMount + "/issuer/" + issuerId)) if err != nil || resp == nil { c.UI.Error(fmt.Sprintf("Error Reading Fully Imported Certificate from %v : %v", diff --git a/command/pki_reissue_intermediate.go b/command/pki_reissue_intermediate.go new file mode 100644 index 000000000..be7e57152 --- /dev/null +++ b/command/pki_reissue_intermediate.go @@ -0,0 +1,297 @@ +package command + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "fmt" + "io" + "net" + "net/url" + "os" + "strings" + + "github.com/posener/complete" +) + +type PKIReIssueCACommand struct { + *BaseCommand + + flagConfig string + flagReturnIndicator string + flagDefaultDisabled bool + flagList bool + + flagKeyStorageSource string + flagNewIssuerName string +} + +func (c *PKIReIssueCACommand) Synopsis() string { + return "Uses a parent certificate and a template certificate to create a new issuer on a child mount" +} + +func (c *PKIReIssueCACommand) Help() string { + helpText := ` +Usage: vault pki reissue PARENT TEMPLATE CHILD_MOUNT options +` + return strings.TrimSpace(helpText) +} + +func (c *PKIReIssueCACommand) Flags() *FlagSets { + set := c.flagSet(FlagSetHTTP | FlagSetOutputFormat) + f := set.NewFlagSet("Command Options") + + f.StringVar(&StringVar{ + Name: "type", + Target: &c.flagKeyStorageSource, + Default: "internal", + EnvVar: "", + Usage: `Options are “existing” - to use an existing key inside vault, “internal” - to generate a new key inside vault, or “kms” - to link to an external key. Exported keys are not available through this API.`, + Completion: complete.PredictSet("internal", "existing", "kms"), + }) + + f.StringVar(&StringVar{ + Name: "issuer_name", + Target: &c.flagNewIssuerName, + Default: "", + EnvVar: "", + Usage: `If present, the newly created issuer will be given this name`, + }) + + return set +} + +func (c *PKIReIssueCACommand) Run(args []string) int { + // Parse Args + f := c.Flags() + if err := f.Parse(args); err != nil { + c.UI.Error(err.Error()) + return 1 + } + args = f.Args() + + if len(args) < 3 { + c.UI.Error("Not enough arguments: expected parent issuer and child-mount location and some key_value argument") + return 1 + } + + stdin := (io.Reader)(os.Stdin) + userData, err := parseArgsData(stdin, args[3:]) + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to parse K=V data: %s", err)) + return 1 + } + + // Check We Have a Client + client, err := c.Client() + if err != nil { + c.UI.Error(fmt.Sprintf("Failed to obtain client: %v", err)) + return 1 + } + + parentIssuer := sanitizePath(args[0]) // /pki/issuer/default + intermediateMount := sanitizePath(args[2]) + + templateCertificateResp, err := client.Logical().Read(sanitizePath(args[1])) + if err != nil { + c.UI.Error(fmt.Sprintf("Error fetching template certificate %v : %v", sanitizePath(args[1]), err)) + return 1 + } + templateCertificateRaw, ok := templateCertificateResp.Data["certificate"] + if !ok { + c.UI.Error(fmt.Sprintf("No Certificate Field Found at %v instead found : %v", sanitizePath(args[1]), templateCertificateResp)) + return 1 + } + certificatePemString := templateCertificateRaw.(string) + certificatePem := []byte(certificatePemString) + certificateBlock, _ := pem.Decode(certificatePem) + certificate, err := x509.ParseCertificate(certificateBlock.Bytes) + if err != nil { + c.UI.Error(fmt.Sprintf("Error parsing template certificate at %v : %v", sanitizePath(args[1]), err)) + return 1 + } + + useExistingKey := c.flagKeyStorageSource == "existing" + keyRef := "" + if useExistingKey { // TODO: Better Error information + keyRef = templateCertificateResp.Data["key_id"].(string) + } + + templateData, err := parseTemplateCertificate(*certificate, useExistingKey, keyRef) + data := updateTemplateWithData(templateData, userData) + + return pkiIssue(c.BaseCommand, parentIssuer, intermediateMount, c.flagNewIssuerName, c.flagKeyStorageSource, data) +} + +func updateTemplateWithData(template map[string]interface{}, changes map[string]interface{}) map[string]interface{} { + data := map[string]interface{}{} + + for key, value := range template { + data[key] = value + } + + // ttl and not_after set the same thing. Delete template ttl if using not_after: + if _, ok := changes["not_after"]; ok { + delete(data, "ttl") + } + + // If we are updating the key_type, do not set key_bits + if _, ok := changes["key_type"]; ok && changes["key_type"] != template["key_type"] { + delete(data, "key_bits") + } + + for key, value := range changes { + data[key] = value + } + + return data +} + +func parseTemplateCertificate(certificate x509.Certificate, useExistingKey bool, keyRef string) (templateData map[string]interface{}, err error) { + // Generate Certificate Signing Parameters + templateData = map[string]interface{}{ + "common_name": certificate.Subject.CommonName, + "alt_names": makeAltNamesCommaSeparatedString(certificate.DNSNames, certificate.EmailAddresses), + "ip_sans": makeIpAddressCommaSeparatedString(certificate.IPAddresses), + "uri_sans": makeUriCommaSeparatedString(certificate.URIs), + // other_sans (string: "") - Specifies custom OID/UTF8-string SANs. These must match values specified on the role in allowed_other_sans (see role creation for allowed_other_sans globbing rules). The format is the same as OpenSSL: ;: where the only current valid type is UTF8. This can be a comma-delimited list or a JSON string slice. + // Punting on Other_SANs, shouldn't really be on CAs + "signature_bits": findSignatureBits(certificate.SignatureAlgorithm), + "exclude_cn_from_sans": determineExcludeCnFromSans(certificate), + "ou": certificate.Subject.OrganizationalUnit, + "organization": certificate.Subject.Organization, + "country": certificate.Subject.Country, + "locality": certificate.Subject.Locality, + "province": certificate.Subject.Province, + "street_address": certificate.Subject.StreetAddress, + "postal_code": certificate.Subject.PostalCode, + "serial_number": certificate.Subject.SerialNumber, + "ttl": (certificate.NotAfter.Sub(certificate.NotBefore)).String(), + "max_path_length": certificate.MaxPathLen, + "permitted_dns_domains": strings.Join(certificate.PermittedDNSDomains, ","), + "use_pss": isPSS(certificate.SignatureAlgorithm), + } + + if useExistingKey { + templateData["skid"] = hex.EncodeToString(certificate.SubjectKeyId) // TODO: Double Check this with someone + if keyRef == "" { + return nil, fmt.Errorf("unable to create certificate template for existing key without a key_id") + } + templateData["key_ref"] = keyRef + } else { + templateData["key_type"] = getKeyType(certificate.PublicKeyAlgorithm.String()) + templateData["key_bits"] = findBitLength(certificate.PublicKey) + } + + return templateData, nil +} + +func isPSS(algorithm x509.SignatureAlgorithm) bool { + switch algorithm { + case x509.SHA384WithRSAPSS, x509.SHA512WithRSAPSS, x509.SHA256WithRSAPSS: + return true + default: + return false + } +} + +func makeAltNamesCommaSeparatedString(names []string, emails []string) string { + return strings.Join(names, ",") + "," + strings.Join(emails, ",") +} + +func makeUriCommaSeparatedString(uris []*url.URL) string { + stringAddresses := make([]string, len(uris)) + for i, uri := range uris { + stringAddresses[i] = uri.String() + } + return strings.Join(stringAddresses, ",") +} + +func makeIpAddressCommaSeparatedString(addresses []net.IP) string { + stringAddresses := make([]string, len(addresses)) + for i, address := range addresses { + stringAddresses[i] = address.String() + } + return strings.Join(stringAddresses, ",") +} + +func determineExcludeCnFromSans(certificate x509.Certificate) bool { + cn := certificate.Subject.CommonName + if cn == "" { + return false + } + + emails := certificate.EmailAddresses + for _, email := range emails { + if email == cn { + return false + } + } + + dnses := certificate.DNSNames + for _, dns := range dnses { + if dns == cn { + return false + } + } + + return true +} + +func findBitLength(publicKey any) int { + if publicKey == nil { + return 0 + } + switch pub := publicKey.(type) { + case *rsa.PublicKey: + return pub.N.BitLen() + case *ecdsa.PublicKey: + switch pub.Curve { + case elliptic.P224(): + return 224 + case elliptic.P256(): + return 256 + case elliptic.P384(): + return 384 + case elliptic.P521(): + return 521 + default: + return 0 + } + default: + return 0 + } +} + +func findSignatureBits(algo x509.SignatureAlgorithm) int { + switch algo { + case x509.MD2WithRSA, x509.MD5WithRSA, x509.SHA1WithRSA, x509.DSAWithSHA1, x509.ECDSAWithSHA1: + return -1 + case x509.SHA256WithRSA, x509.DSAWithSHA256, x509.ECDSAWithSHA256, x509.SHA256WithRSAPSS: + return 256 + case x509.SHA384WithRSA, x509.ECDSAWithSHA384, x509.SHA384WithRSAPSS: + return 384 + case x509.SHA512WithRSA, x509.SHA512WithRSAPSS, x509.ECDSAWithSHA512: + return 512 + case x509.PureEd25519: + return 0 + default: + return -1 + } +} + +func getKeyType(goKeyType string) string { + switch goKeyType { + case "RSA": + return "rsa" + case "ECDSA": + return "ec" + case "Ed25519": + return "ed25519" + default: + return "" + } +} diff --git a/command/pki_reissue_intermediate_test.go b/command/pki_reissue_intermediate_test.go new file mode 100644 index 000000000..928449344 --- /dev/null +++ b/command/pki_reissue_intermediate_test.go @@ -0,0 +1,195 @@ +package command + +import ( + "bytes" + "testing" + + "github.com/hashicorp/vault/api" +) + +// TestPKIReIssueIntermediate tests that the pki reissue command line tool accurately copies information from the +// template certificate to the newly issued certificate, by issuing and reissuing several certificates and seeing how +// they related to each other. +func TestPKIReIssueIntermediate(t *testing.T) { + t.Parallel() + + client, closer := testVaultServer(t) + defer closer() + + // Relationship Map to Create + // pki-root | pki-newroot | pki-empty + // RootX1 RootX2 RootX4 RootX3 + // | | + // ---------------------------------------------- + // v v + // IntX1 IntX2 pki-int + // | | + // v v + // IntX3 (-----------------------) IntX3 + // + // Here X1,X2 have the same name (same mount) + // RootX4 uses the same key as RootX1 (but a different common_name/subject) + // RootX3 has the same name, and is on a different mount + // RootX1 has issued IntX1; RootX3 has issued IntX2 + createComplicatedIssuerSetUpWithReIssueIntermediate(t, client) + + runPkiVerifySignTests(t, client) + + runPkiListIntermediateTests(t, client) +} + +func createComplicatedIssuerSetUpWithReIssueIntermediate(t *testing.T, client *api.Client) { + // Relationship Map to Create + // pki-root | pki-newroot | pki-empty + // RootX1 RootX2 RootX4 RootX3 + // | | + // ---------------------------------------------- + // v v + // IntX1 IntX2 pki-int + // | | + // v v + // IntX3 (-----------------------) IntX3 + // + // Here X1,X2 have the same name (same mount) + // RootX4 uses the same key as RootX1 (but a different common_name/subject) + // RootX3 has the same name, and is on a different mount + // RootX1 has issued IntX1; RootX3 has issued IntX2 + + if err := client.Sys().Mount("pki-root", &api.MountInput{ + Type: "pki", + Config: api.MountConfigInput{ + MaxLeaseTTL: "36500d", + }, + }); err != nil { + t.Fatalf("pki mount error: %#v", err) + } + + if err := client.Sys().Mount("pki-newroot", &api.MountInput{ + Type: "pki", + Config: api.MountConfigInput{ + MaxLeaseTTL: "36500d", + }, + }); err != nil { + t.Fatalf("pki mount error: %#v", err) + } + + if err := client.Sys().Mount("pki-int", &api.MountInput{ + Type: "pki", + Config: api.MountConfigInput{ + MaxLeaseTTL: "36500d", + }, + }); err != nil { + t.Fatalf("pki mount error: %#v", err) + } + + // Used to check handling empty list responses: Not Used for Any Issuers / Certificates + if err := client.Sys().Mount("pki-empty", &api.MountInput{ + Type: "pki", + Config: api.MountConfigInput{}, + }); err != nil { + t.Fatalf("pki mount error: %#v", err) + } + + resp, err := client.Logical().Write("pki-root/root/generate/internal", map[string]interface{}{ + "key_type": "ec", + "common_name": "Root X", + "ttl": "3650d", + "issuer_name": "rootX1", + "key_name": "rootX1", + }) + if err != nil || resp == nil { + t.Fatalf("failed to prime CA: %v", err) + } + + resp, err = client.Logical().Write("pki-root/root/generate/internal", map[string]interface{}{ + "key_type": "ec", + "common_name": "Root X", + "ttl": "3650d", + "issuer_name": "rootX2", + }) + if err != nil || resp == nil { + t.Fatalf("failed to prime CA: %v", err) + } + + if resp, err := client.Logical().Write("pki-newroot/root/generate/internal", map[string]interface{}{ + "key_type": "ec", + "common_name": "Root X", + "ttl": "3650d", + "issuer_name": "rootX3", + }); err != nil || resp == nil { + t.Fatalf("failed to prime CA: %v", err) + } + + if resp, err := client.Logical().Write("pki-root/root/generate/existing", map[string]interface{}{ + "common_name": "Root X4", + "ttl": "3650d", + "issuer_name": "rootX4", + "key_ref": "rootX1", + }); err != nil || resp == nil { + t.Fatalf("failed to prime CA: %v", err) + } + + stdout := bytes.NewBuffer(nil) + stderr := bytes.NewBuffer(nil) + runOpts := &RunOptions{ + Stdout: stdout, + Stderr: stderr, + Client: client, + } + + // Intermediate X1 + intX1CallArgs := []string{ + "pki", "issue", "-format=json", "-issuer_name=intX1", + "pki-root/issuer/rootX1", + "pki-int/", + "key_type=rsa", + "common_name=Int X1", + "ou=thing", + "ttl=3650d", + } + codeOut := RunCustom(intX1CallArgs, runOpts) + if codeOut != 0 { + t.Fatalf("error issuing intermediate X1, code: %d \n stdout: %v \n stderr: %v", codeOut, stdout, stderr) + } + + // Intermediate X2 - using ReIssue + intX2CallArgs := []string{ + "pki", "reissue", "-format=json", "-issuer_name=intX2", + "pki-newroot/issuer/rootX3", + "pki-int/issuer/intX1", + "pki-int/", + "key_type=ec", + "common_name=Int X2", + } + codeOut = RunCustom(intX2CallArgs, runOpts) + if codeOut != 0 { + t.Fatalf("error issuing intermediate X2, code: %d \n stdout: %v \n stderr: %v", codeOut, stdout, stderr) + } + + // Intermediate X3 + intX3OriginalCallArgs := []string{ + "pki", "issue", "-format=json", "-issuer_name=intX3", + "pki-int/issuer/intX1", + "pki-int/", + "key_type=ec", + "use_pss=true", // This is meaningful because rootX1 is an RSA key + "signature_bits=512", + "common_name=Int X3", + "ttl=3650d", + } + codeOut = RunCustom(intX3OriginalCallArgs, runOpts) + if codeOut != 0 { + t.Fatalf("error issuing intermediate X3, code: %d \n stdout: %v \n stderr: %v", codeOut, stdout, stderr) + } + + intX3AdaptedCallArgs := []string{ + "pki", "reissue", "-format=json", "-issuer_name=intX3also", "-type=existing", + "pki-int/issuer/intX2", // This is a EC key + "pki-int/issuer/intX3", // This template includes use_pss = true which can't be accomodated + "pki-int/", + } + codeOut = RunCustom(intX3AdaptedCallArgs, runOpts) + if codeOut != 0 { + t.Fatalf("error issuing intermediate X3also, code: %d \n stdout: %v \n stderr: %v", codeOut, stdout, stderr) + } +}