diff --git a/.changelog/17309.txt b/.changelog/17309.txt new file mode 100644 index 000000000..8159f6293 --- /dev/null +++ b/.changelog/17309.txt @@ -0,0 +1,3 @@ +```release-note:improvement +cli: Add the ability to customize the details of the CA when running `nomad tls ca create` +``` \ No newline at end of file diff --git a/command/tls_ca_create.go b/command/tls_ca_create.go index 3c9466986..198edae87 100644 --- a/command/tls_ca_create.go +++ b/command/tls_ca_create.go @@ -34,6 +34,27 @@ type TLSCACreateCommand struct { // additionalDomain provides a list of restricted domains to the CA which // will then reject any domains other than these. additionalDomain flags.StringFlag + + // country is used to set a country code for the CA + country string + + // postalCode is used to set a postal code for the CA + postalCode string + + // province is used to set a province for the CA + province string + + // locality is used to set a locality for the CA + locality string + + // streetAddress is used to set a street address for the CA + streetAddress string + + // organization is used to set an organization for the CA + organization string + + // organizationalUnit is used to set an organizational unit for the CA + organizationalUnit string } func (c *TLSCACreateCommand) Help() string { @@ -53,6 +74,9 @@ CA Create Options: -common-name Common Name of CA. Defaults to "Nomad Agent CA". + -country + Country of the CA. Defaults to "US". + -days Provide number of days the CA is valid for from now on. Defaults to 5 years or 1825 days. @@ -61,12 +85,31 @@ CA Create Options: Domain of Nomad cluster. Only used in combination with -name-constraint. Defaults to "nomad". + -locality + Locality of the CA. Defaults to "San Francisco". + -name-constraint Enables the DNS name restriction functionality to the CA. Results in the CA rejecting certificates for any other DNS zone. If enabled, localhost and the value of -domain will be added to the allowed DNS zones field. If the UI is going to be served over HTTPS its hostname must be added with -additional-domain. Defaults to false. + + -organization + Organization of the CA. Defaults to "HashiCorp Inc.". + + -organizational-unit + Organizational Unit of the CA. Defaults to "Nomad". + + -postal-code + Postal Code of the CA. Defaults to "94105". + + -province + Province of the CA. Defaults to "CA". + + -street-address + Street Address of the CA. Defaults to "101 Second Street". + ` return strings.TrimSpace(helpText) } @@ -74,11 +117,18 @@ CA Create Options: func (c *TLSCACreateCommand) AutocompleteFlags() complete.Flags { return mergeAutocompleteFlags(c.Meta.AutocompleteFlags(FlagSetClient), complete.Flags{ - "-additional-domain": complete.PredictAnything, - "-common-name": complete.PredictAnything, - "-days": complete.PredictAnything, - "-domain": complete.PredictAnything, - "-name-constraint": complete.PredictAnything, + "-additional-domain": complete.PredictAnything, + "-common-name": complete.PredictAnything, + "-days": complete.PredictAnything, + "-country": complete.PredictAnything, + "-domain": complete.PredictAnything, + "-locality": complete.PredictAnything, + "-name-constraint": complete.PredictAnything, + "-organization": complete.PredictAnything, + "-organizational-unit": complete.PredictAnything, + "-postal-code": complete.PredictAnything, + "-province": complete.PredictAnything, + "-street-address": complete.PredictAnything, }) } @@ -97,10 +147,17 @@ func (c *TLSCACreateCommand) Run(args []string) int { flagSet := c.Meta.FlagSet(c.Name(), FlagSetClient) flagSet.Usage = func() { c.Ui.Output(c.Help()) } flagSet.Var(&c.additionalDomain, "additional-domain", "") - flagSet.IntVar(&c.days, "days", 1825, "") + flagSet.IntVar(&c.days, "days", 0, "") flagSet.BoolVar(&c.constraint, "name-constraint", false, "") - flagSet.StringVar(&c.domain, "domain", "nomad", "") + flagSet.StringVar(&c.domain, "domain", "", "") flagSet.StringVar(&c.commonName, "common-name", "", "") + flagSet.StringVar(&c.country, "country", "", "") + flagSet.StringVar(&c.postalCode, "postal-code", "", "") + flagSet.StringVar(&c.province, "province", "", "") + flagSet.StringVar(&c.locality, "locality", "", "") + flagSet.StringVar(&c.streetAddress, "street-address", "", "") + flagSet.StringVar(&c.organization, "organization", "", "") + flagSet.StringVar(&c.organizationalUnit, "organizational-unit", "", "") if err := flagSet.Parse(args); err != nil { return 1 } @@ -112,6 +169,32 @@ func (c *TLSCACreateCommand) Run(args []string) int { c.Ui.Error(commandErrorText(c)) return 1 } + if c.IsCustom() && c.days != 0 || c.IsCustom() { + c.domain = "nomad" + } else { + if c.commonName == "" { + c.Ui.Error("Please provide the -common-name flag when customizing the CA") + c.Ui.Error(commandErrorText(c)) + return 1 + } + if c.country == "" { + c.Ui.Error("Please provide the -country flag when customizing the CA") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + if c.organization == "" { + c.Ui.Error("Please provide the -organization flag when customizing the CA") + c.Ui.Error(commandErrorText(c)) + return 1 + } + + if c.organizationalUnit == "" { + c.Ui.Error("Please provide the -organizational-unit flag when customizing the CA") + c.Ui.Error(commandErrorText(c)) + return 1 + } + } if c.domain != "" && c.domain != "nomad" && !c.constraint { c.Ui.Error("Please provide the -name-constraint flag to use a custom domain constraint") return 1 @@ -143,7 +226,18 @@ func (c *TLSCACreateCommand) Run(args []string) int { constraints = append(constraints, c.additionalDomain...) } - ca, pk, err := tlsutil.GenerateCA(tlsutil.CAOpts{Name: c.commonName, Days: c.days, Domain: c.domain, PermittedDNSDomains: constraints}) + ca, pk, err := tlsutil.GenerateCA(tlsutil.CAOpts{ + Name: c.commonName, + Days: c.days, + PermittedDNSDomains: constraints, + Country: c.country, + PostalCode: c.postalCode, + Province: c.province, + Locality: c.locality, + StreetAddress: c.streetAddress, + Organization: c.organization, + OrganizationalUnit: c.organizationalUnit, + }) if err != nil { c.Ui.Error(err.Error()) return 1 @@ -163,3 +257,17 @@ func (c *TLSCACreateCommand) Run(args []string) int { return 0 } + +// IsCustom checks whether any of TLSCACreateCommand parameters have been populated with +// non-default values. +func (c *TLSCACreateCommand) IsCustom() bool { + return c.commonName == "" && + c.country == "" && + c.postalCode == "" && + c.province == "" && + c.locality == "" && + c.streetAddress == "" && + c.organization == "" && + c.organizationalUnit == "" + +} diff --git a/command/tls_ca_create_test.go b/command/tls_ca_create_test.go index d4da1cbce..2755f079d 100644 --- a/command/tls_ca_create_test.go +++ b/command/tls_ca_create_test.go @@ -6,7 +6,6 @@ package command import ( "crypto/x509" "os" - "strings" "testing" "time" @@ -47,6 +46,10 @@ func TestCACreateCommand(t *testing.T) { "-name-constraint=true", "-domain=foo", "-additional-domain=bar", + "-common-name=CustomCA", + "-country=ZZ", + "-organization=CustOrg", + "-organizational-unit=CustOrgUnit", }, "foo-agent-ca.pem", "foo-agent-ca-key.pem", @@ -55,24 +58,20 @@ func TestCACreateCommand(t *testing.T) { require.True(t, cert.PermittedDNSDomainsCritical) require.Len(t, cert.PermittedDNSDomains, 4) require.ElementsMatch(t, cert.PermittedDNSDomains, []string{"nomad", "foo", "localhost", "bar"}) + require.Equal(t, cert.Issuer.Organization, []string{"CustOrg"}) + require.Equal(t, cert.Issuer.OrganizationalUnit, []string{"CustOrgUnit"}) + require.Equal(t, cert.Issuer.Country, []string{"ZZ"}) + require.Contains(t, cert.Issuer.CommonName, "CustomCA") }, }, - {"with common-name", + {"ca custom date", []string{ - "-common-name=foo", + "-days=365", }, "nomad-agent-ca.pem", "nomad-agent-ca-key.pem", func(t *testing.T, cert *x509.Certificate) { - require.Equal(t, cert.Subject.CommonName, "foo") - }, - }, - {"without common-name", - []string{}, - "nomad-agent-ca.pem", - "nomad-agent-ca-key.pem", - func(t *testing.T, cert *x509.Certificate) { - require.True(t, strings.HasPrefix(cert.Subject.CommonName, "Nomad Agent CA")) + require.Equal(t, 365*24*time.Hour, time.Until(cert.NotAfter).Round(24*time.Hour)) }, }, } @@ -97,5 +96,4 @@ func TestCACreateCommand(t *testing.T) { require.NoError(t, os.Remove(tc.keyPath)) }) } - } diff --git a/helper/tlsutil/generate.go b/helper/tlsutil/generate.go index 75a1a68ad..1db753fec 100644 --- a/helper/tlsutil/generate.go +++ b/helper/tlsutil/generate.go @@ -14,6 +14,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "errors" "fmt" "math/big" "net" @@ -66,7 +67,13 @@ type CAOpts struct { Serial *big.Int Days int PermittedDNSDomains []string - Domain string + Country string + PostalCode string + Province string + Locality string + StreetAddress string + Organization string + OrganizationalUnit string Name string } @@ -81,10 +88,28 @@ type CertOpts struct { ExtKeyUsage []x509.ExtKeyUsage } +// IsCustom checks whether any of CAOpts parameters have been populated with +// non-default values. +func (c *CAOpts) IsCustom() bool { + return c.Country == "" && + c.PostalCode == "" && + c.Province == "" && + c.Locality == "" && + c.StreetAddress == "" && + c.Organization == "" && + c.OrganizationalUnit == "" && + c.Name == "" +} + // GenerateCA generates a new CA for agent TLS (not to be confused with Connect TLS) func GenerateCA(opts CAOpts) (string, string, error) { - signer := opts.Signer - var pk string + var ( + id []byte + pk string + err error + signer = opts.Signer + sn = opts.Serial + ) if signer == nil { var err error signer, pk, err = GeneratePrivateKey() @@ -93,12 +118,11 @@ func GenerateCA(opts CAOpts) (string, string, error) { } } - id, err := keyID(signer.Public()) + id, err = keyID(signer.Public()) if err != nil { return "", "", err } - sn := opts.Serial if sn == nil { var err error sn, err = GenerateSerialNumber() @@ -106,32 +130,55 @@ func GenerateCA(opts CAOpts) (string, string, error) { return "", "", err } } - name := opts.Name - if name == "" { - name = fmt.Sprintf("Nomad Agent CA %d", sn) - } - days := opts.Days - if opts.Days == 0 { - days = 365 + if opts.IsCustom() { + opts.Name = fmt.Sprintf("Nomad Agent CA %d", sn) + if opts.Days == 0 { + opts.Days = 1825 + } + opts.Country = "US" + opts.PostalCode = "94105" + opts.Province = "CA" + opts.Locality = "San Francisco" + opts.StreetAddress = "101 Second Street" + opts.Organization = "HashiCorp Inc." + opts.OrganizationalUnit = "Nomad" + } else { + if opts.Name == "" { + return "", "", errors.New("common name value not provided") + } else { + opts.Name = fmt.Sprintf("%s %d", opts.Name, sn) + } + if opts.Country == "" { + return "", "", errors.New("country value not provided") + } + + if opts.Organization == "" { + return "", "", errors.New("organization value not provided") + } + + if opts.OrganizationalUnit == "" { + return "", "", errors.New("organizational unit value not provided") + } } // Create the CA cert template := x509.Certificate{ SerialNumber: sn, Subject: pkix.Name{ - Country: []string{"US"}, - PostalCode: []string{"94105"}, - Province: []string{"CA"}, - Locality: []string{"San Francisco"}, - StreetAddress: []string{"101 Second Street"}, - Organization: []string{"HashiCorp Inc."}, - CommonName: name, + Country: []string{opts.Country}, + PostalCode: []string{opts.PostalCode}, + Province: []string{opts.Province}, + Locality: []string{opts.Locality}, + StreetAddress: []string{opts.StreetAddress}, + Organization: []string{opts.Organization}, + OrganizationalUnit: []string{opts.OrganizationalUnit}, + CommonName: opts.Name, }, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature, IsCA: true, - NotAfter: time.Now().AddDate(0, 0, days), + NotAfter: time.Now().AddDate(0, 0, opts.Days), NotBefore: time.Now(), AuthorityKeyId: id, SubjectKeyId: id, diff --git a/helper/tlsutil/generate_test.go b/helper/tlsutil/generate_test.go index 31671558c..55431b543 100644 --- a/helper/tlsutil/generate_test.go +++ b/helper/tlsutil/generate_test.go @@ -94,7 +94,7 @@ func TestGenerateCA(t *testing.T) { require.Equal(t, true, cert.BasicConstraintsValid) require.WithinDuration(t, cert.NotBefore, time.Now(), time.Minute) - require.WithinDuration(t, cert.NotAfter, time.Now().AddDate(0, 0, 365), time.Minute) + require.WithinDuration(t, cert.NotAfter, time.Now().AddDate(0, 0, 1825), time.Minute) require.Equal(t, x509.KeyUsageCertSign|x509.KeyUsageCRLSign|x509.KeyUsageDigitalSignature, cert.KeyUsage) }) @@ -112,10 +112,110 @@ func TestGenerateCA(t *testing.T) { require.Equal(t, true, cert.BasicConstraintsValid) require.WithinDuration(t, cert.NotBefore, time.Now(), time.Minute) - require.WithinDuration(t, cert.NotAfter, time.Now().AddDate(0, 0, 365), time.Minute) + require.WithinDuration(t, cert.NotAfter, time.Now().AddDate(0, 0, 1825), time.Minute) require.Equal(t, x509.KeyUsageCertSign|x509.KeyUsageCRLSign|x509.KeyUsageDigitalSignature, cert.KeyUsage) }) + + t.Run("Custom CA", func(t *testing.T) { + ca, pk, err := GenerateCA(CAOpts{ + Days: 6, + PermittedDNSDomains: []string{"domain1.com"}, + Country: "ZZ", + PostalCode: "0000", + Province: "CustProvince", + Locality: "CustLocality", + StreetAddress: "CustStreet", + Organization: "CustOrg", + OrganizationalUnit: "CustUnit", + Name: "Custom CA", + }) + require.NoError(t, err) + require.NotEmpty(t, ca) + require.NotEmpty(t, pk) + + cert, err := parseCert(ca) + require.NoError(t, err) + require.True(t, strings.HasPrefix(cert.Subject.CommonName, "Custom CA")) + require.True(t, strings.Contains(cert.PermittedDNSDomains[0], "domain1.com")) + require.True(t, strings.Contains(cert.Subject.Country[0], "ZZ")) + require.True(t, strings.Contains(cert.Subject.PostalCode[0], "0000")) + require.True(t, strings.Contains(cert.Subject.Province[0], "CustProvince")) + require.True(t, strings.Contains(cert.Subject.Locality[0], "CustLocality")) + require.True(t, strings.Contains(cert.Subject.StreetAddress[0], "CustStreet")) + require.True(t, strings.Contains(cert.Subject.Organization[0], "CustOrg")) + require.True(t, strings.Contains(cert.Subject.OrganizationalUnit[0], "CustUnit")) + require.Equal(t, true, cert.IsCA) + require.Equal(t, true, cert.BasicConstraintsValid) + + require.WithinDuration(t, cert.NotBefore, time.Now(), time.Minute) + require.WithinDuration(t, cert.NotAfter, time.Now().AddDate(0, 0, 6), time.Minute) + + require.Equal(t, x509.KeyUsageCertSign|x509.KeyUsageCRLSign|x509.KeyUsageDigitalSignature, cert.KeyUsage) + }) + + t.Run("Custom CA Custom Date", func(t *testing.T) { + ca, pk, err := GenerateCA(CAOpts{ + Days: 365, + }) + require.NoError(t, err) + require.NotEmpty(t, ca) + require.NotEmpty(t, pk) + + cert, err := parseCert(ca) + require.WithinDuration(t, cert.NotAfter, time.Now().AddDate(0, 0, 365), time.Minute) + }) + + t.Run("Custom CA No CN", func(t *testing.T) { + ca, pk, err := GenerateCA(CAOpts{ + Days: 6, + PermittedDNSDomains: []string{"domain1.com"}, + Locality: "CustLocality", + }) + require.ErrorContains(t, err, "common name value not provided") + require.Empty(t, ca) + require.Empty(t, pk) + }) + + t.Run("Custom CA No Country", func(t *testing.T) { + ca, pk, err := GenerateCA(CAOpts{ + Days: 6, + PermittedDNSDomains: []string{"domain1.com"}, + Name: "Custom CA", + Locality: "CustLocality", + }) + require.ErrorContains(t, err, "country value not provided") + require.Empty(t, ca) + require.Empty(t, pk) + }) + + t.Run("Custom CA No Organization", func(t *testing.T) { + ca, pk, err := GenerateCA(CAOpts{ + Days: 6, + PermittedDNSDomains: []string{"domain1.com"}, + Name: "Custom CA", + Country: "ZZ", + Locality: "CustLocality", + }) + require.ErrorContains(t, err, "organization value not provided") + // require.NoError(t, err) + require.Empty(t, ca) + require.Empty(t, pk) + }) + + t.Run("Custom CA No Organizational Unit", func(t *testing.T) { + ca, pk, err := GenerateCA(CAOpts{ + Days: 6, + PermittedDNSDomains: []string{"domain1.com"}, + Name: "Custom CA", + Country: "ZZ", + Locality: "CustLocality", + Organization: "CustOrg", + }) + require.ErrorContains(t, err, "organizational unit value not provided") + require.Empty(t, ca) + require.Empty(t, pk) + }) } func TestGenerateCert(t *testing.T) { @@ -123,10 +223,16 @@ func TestGenerateCert(t *testing.T) { signer, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.Nil(t, err) - ca, _, err := GenerateCA(CAOpts{Signer: signer}) + ca, _, err := GenerateCA(CAOpts{ + Name: "Custom CA", + Country: "ZZ", + Organization: "CustOrg", + OrganizationalUnit: "CustOrgUnit", + Signer: signer}, + ) require.Nil(t, err) - DNSNames := []string{"server.dc1.consul"} + DNSNames := []string{"server.dc1.nomad"} IPAddresses := []net.IP{net.ParseIP("123.234.243.213")} extKeyUsage := []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth} name := "Cert Name" @@ -150,7 +256,7 @@ func TestGenerateCert(t *testing.T) { caID, err := keyID(signer.Public()) require.Nil(t, err) require.Equal(t, caID, cert.AuthorityKeyId) - require.Contains(t, cert.Issuer.CommonName, "Nomad Agent CA") + require.Contains(t, cert.Issuer.CommonName, "Custom CA") require.Equal(t, false, cert.IsCA) require.WithinDuration(t, cert.NotBefore, time.Now(), time.Minute) diff --git a/nomad/rpc_test.go b/nomad/rpc_test.go index 0bebfd57f..4ac6ffb4c 100644 --- a/nomad/rpc_test.go +++ b/nomad/rpc_test.go @@ -1370,7 +1370,13 @@ func newTLSTestHelper(t *testing.T) tlsTestHelper { } // Generate CA certificate and write it to disk. - h.caPEM, h.pk, err = tlsutil.GenerateCA(tlsutil.CAOpts{Days: 5, Domain: "nomad"}) + h.caPEM, h.pk, err = tlsutil.GenerateCA(tlsutil.CAOpts{ + Name: "Nomad CA", + Country: "ZZ", + Days: 5, + Organization: "CustOrgUnit", + OrganizationalUnit: "CustOrgUnit", + }) must.NoError(t, err) err = os.WriteFile(filepath.Join(h.dir, "ca.pem"), []byte(h.caPEM), 0600) diff --git a/website/content/docs/commands/tls/ca-create.mdx b/website/content/docs/commands/tls/ca-create.mdx index 01deb51ec..3e90a45c4 100644 --- a/website/content/docs/commands/tls/ca-create.mdx +++ b/website/content/docs/commands/tls/ca-create.mdx @@ -24,7 +24,9 @@ nomad tls ca create [options] `-additional-domain`. Can be used multiple times. This option can only used in combination with `-domain` and `-name-constraint`. -- `common-name`: Common Name of CA. Defaults to Nomad Agent CA. +- `-common-name`: Common Name of CA. Defaults to Nomad Agent CA. + +- `-country`: Country of the CA. Defaults to "US". - `-days=`: Provide number of days the CA is valid for from now on, defaults to 5 years. @@ -32,6 +34,8 @@ nomad tls ca create [options] - `-domain=`: Domain of nomad cluster. Only used in combination with `-name-constraint`. Defaults to `nomad`. +- `-locality`: Locality of the CA. Defaults to "San Francisco". + - `-name-constraint`: Add name constraints for the CA. Results in rejecting certificates for other DNS than specified. If set to true, "localhost" and `-domain` will be added to the allowed DNS. Defaults to false. @@ -40,6 +44,16 @@ nomad tls ca create [options] Nomad web UI over HTTPS its DNS must be added with `additional-domain`. It is not possible to add that after the fact. +- `-organization`: Organization of the CA. Defaults to "HashiCorp Inc.". + +- `-organizational-unit`: Organizational Unit of the CA. Defaults to "Nomad". + +- `-postal-code`: Postal Code of the CA. Defaults to "94105". + +- `-province`: Province of the CA. Defaults to "CA". + +- `-street-address`: Street Address of the CA. Defaults to "101 Second Street". + ## Example Create CA: