Add the ability to customise the details of the CA (#17309)

Co-authored-by: James Rasell <jrasell@users.noreply.github.com>
This commit is contained in:
Lance Haig 2023-07-11 09:53:09 +02:00 committed by GitHub
parent 2b85290d55
commit 0455389534
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 330 additions and 48 deletions

3
.changelog/17309.txt Normal file
View File

@ -0,0 +1,3 @@
```release-note:improvement
cli: Add the ability to customize the details of the CA when running `nomad tls ca create`
```

View File

@ -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)
}
@ -77,8 +120,15 @@ func (c *TLSCACreateCommand) AutocompleteFlags() complete.Flags {
"-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 == ""
}

View File

@ -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))
})
}
}

View File

@ -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)
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")
}
days := opts.Days
if opts.Days == 0 {
days = 365
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,

View File

@ -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)

View File

@ -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)

View File

@ -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=<int>`: 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=<string>`: 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: