From fe6b6886b2c35c40dfe5ae6a39d0a0431ff9bcaf Mon Sep 17 00:00:00 2001 From: n-marton Date: Mon, 29 Apr 2024 07:44:55 +0200 Subject: [PATCH] add pkiSign function to use vault pki sign api endpoint Signed-off-by: n-marton --- dependency/vault_pki.go | 21 ++-- dependency/vault_pki_test.go | 15 ++- docs/templating-language.md | 52 ++++++++++ template/funcs.go | 196 ++++++++++++++++++++++++++++++++++- template/template.go | 1 + template/template_test.go | 77 +++++++++++++- 6 files changed, 348 insertions(+), 14 deletions(-) diff --git a/dependency/vault_pki.go b/dependency/vault_pki.go index 66cb17ec9..10c4f9c4b 100644 --- a/dependency/vault_pki.go +++ b/dependency/vault_pki.go @@ -36,10 +36,13 @@ type VaultPKIQuery struct { pkiPath string data map[string]interface{} filePath string + // we have a var here for passing the private key to the class functions + // for the cases when we intend to use sign instead of issue + privateKey *string } // NewVaultReadQuery creates a new datacenter dependency. -func NewVaultPKIQuery(urlpath, filepath string, data map[string]interface{}) (*VaultPKIQuery, error) { +func NewVaultPKIQuery(urlpath, filepath string, data map[string]interface{}, privateKey *string) (*VaultPKIQuery, error) { urlpath = strings.TrimSpace(urlpath) urlpath = strings.Trim(urlpath, "/") if urlpath == "" { @@ -52,11 +55,12 @@ func NewVaultPKIQuery(urlpath, filepath string, data map[string]interface{}) (*V } return &VaultPKIQuery{ - stopCh: make(chan struct{}, 1), - sleepCh: make(chan time.Duration, 1), - pkiPath: secretURL.Path, - data: data, - filePath: filepath, + stopCh: make(chan struct{}, 1), + sleepCh: make(chan time.Duration, 1), + pkiPath: secretURL.Path, + data: data, + filePath: filepath, + privateKey: privateKey, }, nil } @@ -107,6 +111,11 @@ func (d *VaultPKIQuery) Fetch(clients *ClientSet, opts *QueryOptions) (interface default: return PemEncoded{}, nil, err } + // In the case that we are using sign vault endpoint we wont have an private key in the response + // so we should pass the one the we generated + if encPems.Key == "" && d.privateKey != nil { + encPems.Key = *d.privateKey + } return respWithMetadata(encPems) } diff --git a/dependency/vault_pki_test.go b/dependency/vault_pki_test.go index ece8a7490..ab88afc6b 100644 --- a/dependency/vault_pki_test.go +++ b/dependency/vault_pki_test.go @@ -17,13 +17,18 @@ import ( ) func Test_VaultPKI_uniqueID(t *testing.T) { - d1, _ := NewVaultPKIQuery("pki/issue/example-dot-com", "/unique_1", nil) + d1, _ := NewVaultPKIQuery("pki/issue/example-dot-com", "/unique_1", nil, nil) id1 := d1.String() - d2, _ := NewVaultPKIQuery("pki/issue/example-dot-com", "/unique_2", nil) + d2, _ := NewVaultPKIQuery("pki/issue/example-dot-com", "/unique_2", nil, nil) id2 := d2.String() if id1 == id2 { t.Errorf("IDs should be unique.\n%s\n%s", id1, id2) } + d3, _ := NewVaultPKIQuery("pki/sign/example-dot-com", "/unique_1", nil, nil) + id3 := d3.String() + if id1 == id3 { + t.Errorf("IDs should be unique.\n%s\n%s", id1, id3) + } } func Test_VaultPKI_notGoodFor(t *testing.T) { @@ -102,7 +107,7 @@ func Test_VaultPKI_fetchPEM(t *testing.T) { "ttl": "2h", "ip_sans": "127.0.0.1,192.168.2.2", } - d, err := NewVaultPKIQuery("pki/issue/example-dot-com", "/dev/null", data) + d, err := NewVaultPKIQuery("pki/issue/example-dot-com", "/dev/null", data, nil) if err != nil { t.Error(err) } @@ -114,7 +119,7 @@ func Test_VaultPKI_fetchPEM(t *testing.T) { t.Errorf("pemsificate not fetched, got: %s", string(encPEM)) } // test path error - d, err = NewVaultPKIQuery("pki/issue/does-not-exist", "/dev/null", data) + d, err = NewVaultPKIQuery("pki/issue/does-not-exist", "/dev/null", data, nil) if err != nil { t.Error(err) } @@ -142,7 +147,7 @@ func Test_VaultPKI_refetch(t *testing.T) { "ttl": "3s", "ip_sans": "127.0.0.1,192.168.2.2", } - d, err := NewVaultPKIQuery("pki/issue/example-dot-com", f.Name(), data) + d, err := NewVaultPKIQuery("pki/issue/example-dot-com", f.Name(), data, nil) if err != nil { t.Fatal(err) } diff --git a/docs/templating-language.md b/docs/templating-language.md index 36e7afc98..3f52b6668 100644 --- a/docs/templating-language.md +++ b/docs/templating-language.md @@ -27,6 +27,7 @@ provides the following functions: + [Write (and Read back)](#write-and-read-back) * [`secrets`](#secrets) * [`pkiCert`](#pkicert) + * [`pkiSign`](#pkisign) * [`service`](#service) * [`services`](#services) * [`tree`](#tree) @@ -736,6 +737,57 @@ to separate files from a template. {{- end -}} ``` +### pkiSign + +Query [Vault][vault] for a PKI certificate. This is pretty similar to `pkiCert` +however, instead of using the `issue` api endpoint it uses the `sign`. This also +means, the private key generation is happening on the consul template side, this +can be quite useful if one generates a high number of certificates with low ttl +which can put high load on the vault servers. + +The templating behaviour is the same as we have in `pkiCert` with a few special +attributes. You also need to pass `key_type=rsa|ec|ed25519` in alignment with your +role on vault server. If you have `use_csr_common_name` and/or `use_csr_sans` as true +on in your role, you should also pass them here, so the CSR will be appended with those +values (they can have any value `use_csr_sans=value` or `use_csr_common_name=value` the +code only check for the key). + + +```golang +{{ with pkiSign "pki/sign/my-domain-dot-com" "common_name=foo.example.com" }} +Certificate: {{ .Cert }} +Private Key: {{ .Key }} +Cert Authority: {{ .CA }} +{{ end }} +``` + +If the pki role has use_csr_common_name=true and use_csr_sans=true +```golang +{{ with pkiSign "pki/sign/my-domain-dot-com" "common_name=foo.example.com" "use_csr_common_name=some" "use_csr_sans=thing" }} +Certificate: {{ .Cert }} +Private Key: {{ .Key }} +Cert Authority: {{ .CA }} +{{ end }} +``` + +If the pki role has `ec` key +```golang +{{ with pkiSign "pki/sign/my-domain-dot-com" "common_name=foo.example.com" key_type="ec" key_bits="521" }} +Certificate: {{ .Cert }} +Private Key: {{ .Key }} +Cert Authority: {{ .CA }} +{{ end }} +``` + +If the pki role has `ed25519` key +```golang +{{ with pkiSign "pki/sign/my-domain-dot-com" "common_name=foo.example.com" key_type="ed25519" }} +Certificate: {{ .Cert }} +Private Key: {{ .Key }} +Cert Authority: {{ .CA }} +{{ end }} +``` + ### `service` Query [Consul][consul] for services based on their health. diff --git a/template/funcs.go b/template/funcs.go index 09db41976..366c5c06d 100644 --- a/template/funcs.go +++ b/template/funcs.go @@ -5,14 +5,22 @@ package template import ( "bytes" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" "crypto/hmac" "crypto/md5" + "crypto/rand" + "crypto/rsa" "crypto/sha256" + "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" + "encoding/pem" "fmt" "io" + "net" "os" "os/exec" "os/user" @@ -407,7 +415,193 @@ func pkiCertFunc(b *Brain, used, missing *dep.Set, destPath string) func(...stri data[k] = v } - d, err := dep.NewVaultPKIQuery(path, destPath, data) + d, err := dep.NewVaultPKIQuery(path, destPath, data, nil) + if err != nil { + return nil, err + } + + used.Add(d) + if value, ok := b.Recall(d); ok { + return value, nil + } + missing.Add(d) + + return nil, nil + } +} + +// pkiSignFunc generates a privatekey and csr, sends the latter to Vault to sign +func pkiSignFunc(b *Brain, used, missing *dep.Set, destPath string) func(...string) (interface{}, error) { + return func(s ...string) (interface{}, error) { + if len(s) == 0 { + return nil, nil + } + + keyType := "rsa" + keyBits := 2048 + + var privateKey any + var rawKey string + var useCSRCommonName bool + var useCSRSans bool + + path, rest := s[0], s[1:] + data := make(map[string]interface{}) + for _, str := range rest { + if len(str) == 0 { + continue + } + parts := strings.SplitN(str, "=", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("not k=v pair %q", str) + } + + k, v := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) + // since we are generating the private key on our end we should not send + // key_type and key_bits to Vault + // use_csr_common_name and use_csr_sans here are meant to mirror the settings on the + // vault role side, so we can configure on our end accordingly + if k != "key_type" && k != "key_bits" && k != "use_csr_common_name" && k != "use_csr_sans" { + data[k] = v + } + // if we passed a key_type and the value is either rsa, ec or ed25519 we override the default value + if k == "key_type" && (v == "rsa" || v == "ed25519" || v == "ec") { + keyType = v + } + // if we passed key_bits we override the default value + if k == "key_bits" { + keyBit, err := strconv.Atoi(v) + if err != nil { + return nil, err + } + keyBits = keyBit + } + // check if we passed use_csr_common_name for later usage + if k == "use_csr_common_name" { + useCSRCommonName = true + } + // check if we passed use_csr_sans for later usage + if k == "use_csr_sans" { + useCSRSans = true + } + } + + var csrTemplate x509.CertificateRequest + + // if we passed use_csr_common_name, that means serverside will expect the commonname from the csr + // so besides adding that param to the csr template, we also remove it from the map we pass later + // to vault, this way we spare a warning from the server side + if useCSRCommonName { + commonName, ok := data["common_name"] + if ok { + csrTemplate.Subject.CommonName = commonName.(string) + } + delete(data, "common_name") + } + // if we passed use_csr_sans, that means serverside will expect the subject alternate names from the csr + // so besides adding that param to the csr template, we also remove it from the map we pass later + if useCSRSans { + subjectAltNames, ok := data["uri_sans"] + if ok { + csrTemplate.DNSNames = strings.Split(subjectAltNames.(string), ",") + } + subjectAltIPs, ok := data["ip_sans"] + if ok { + for _, ip := range strings.Split(subjectAltIPs.(string), ",") { + parsedIP := net.ParseIP(ip) + csrTemplate.IPAddresses = append(csrTemplate.IPAddresses, parsedIP) + } + } + delete(data, "uri_sans") + delete(data, "ip_sans") + } + + // generating private keys and also pem encode them for later usage + if keyType == "rsa" { + key, err := rsa.GenerateKey(rand.Reader, keyBits) + if err != nil { + return nil, err + } + privateKey = key + csrTemplate.SignatureAlgorithm = x509.SHA512WithRSA + marshaledKey := x509.MarshalPKCS1PrivateKey(key) + if err != nil { + return nil, err + } + keyPEMBlock := &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: marshaledKey, + } + rawKey = strings.TrimSpace(string(pem.EncodeToMemory(keyPEMBlock))) + } + if keyType == "ed25519" { + _, key, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + privateKey = key + csrTemplate.SignatureAlgorithm = x509.PureEd25519 + marshaledKey, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, err + } + keyPEMBlock := &pem.Block{ + Type: "PRIVATE KEY", + Bytes: marshaledKey, + } + rawKey = strings.TrimSpace(string(pem.EncodeToMemory(keyPEMBlock))) + } + if keyType == "ec" { + if keyBits == 2048 { + keyBits = 256 + } + var err error + var key *ecdsa.PrivateKey + switch keyBits { + case 224: + key, err = ecdsa.GenerateKey(elliptic.P224(), rand.Reader) + case 256: + key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + case 384: + key, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + case 521: + key, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader) + default: + err = errors.New("Got unknown ec< key bits: " + fmt.Sprintf("%d", keyBits)) + } + if err != nil { + return nil, err + } + privateKey = key + csrTemplate.SignatureAlgorithm = x509.ECDSAWithSHA512 + marshaledKey, err := x509.MarshalECPrivateKey(key) + if err != nil { + return nil, err + } + keyPEMBlock := &pem.Block{ + Type: "EC PRIVATE KEY", + Bytes: marshaledKey, + } + rawKey = strings.TrimSpace(string(pem.EncodeToMemory(keyPEMBlock))) + } + + csr, err := x509.CreateCertificateRequest(rand.Reader, &csrTemplate, privateKey) + if err != nil { + return nil, err + } + + pemBlock := &pem.Block{ + Type: "CERTIFICATE REQUEST", + Headers: nil, + Bytes: csr, + } + pemCsr := string(pem.EncodeToMemory(pemBlock)) + + // we need to pass the actual csr to the sign endpoint + data["csr"] = pemCsr + + // we pass also the private key that we have generated + d, err := dep.NewVaultPKIQuery(path, destPath, data, &rawKey) if err != nil { return nil, err } diff --git a/template/template.go b/template/template.go index 28b5265d1..757245505 100644 --- a/template/template.go +++ b/template/template.go @@ -348,6 +348,7 @@ func funcMap(i *funcMapInput) template.FuncMap { "caRoots": connectCARootsFunc(i.brain, i.used, i.missing), "caLeaf": connectLeafFunc(i.brain, i.used, i.missing), "pkiCert": pkiCertFunc(i.brain, i.used, i.missing, i.destination), + "pkiSign": pkiSignFunc(i.brain, i.used, i.missing, i.destination), // Nomad Functions. "nomadServices": nomadServicesFunc(i.brain, i.used, i.missing), diff --git a/template/template_test.go b/template/template_test.go index a9def4d7a..b300b5dd3 100644 --- a/template/template_test.go +++ b/template/template_test.go @@ -2059,7 +2059,7 @@ func TestTemplate_Execute(t *testing.T) { &ExecuteInput{ Brain: func() *Brain { b := NewBrain() - d, err := dep.NewVaultPKIQuery("pki/issue/egs-dot-com", "/dev/null", nil) + d, err := dep.NewVaultPKIQuery("pki/issue/egs-dot-com", "/dev/null", nil, nil) if err != nil { t.Fatal(err) } @@ -2079,7 +2079,7 @@ func TestTemplate_Execute(t *testing.T) { &ExecuteInput{ Brain: func() *Brain { b := NewBrain() - d, err := dep.NewVaultPKIQuery("pki/issue/egs-dot-com", "/dev/null", nil) + d, err := dep.NewVaultPKIQuery("pki/issue/egs-dot-com", "/dev/null", nil, nil) if err != nil { t.Fatal(err) } @@ -2090,6 +2090,27 @@ func TestTemplate_Execute(t *testing.T) { testCert, false, }, + { + "func_pkiSign_Data_compat", + &NewTemplateInput{ + Contents: `{{ with pkiSign "pki/sign/egs-dot-com" }}{{.Data.Cert}}{{.Data.Key}}{{end}}`, + Destination: "/dev/null", + }, + &ExecuteInput{ + Brain: func() *Brain { + pkey := testSignPrivateKey + b := NewBrain() + d, err := dep.NewVaultPKIQuery("pki/sign/egs-dot-com", "/dev/null", nil, &pkey) + if err != nil { + t.Fatal(err) + } + b.Remember(d, dep.PemEncoded{Cert: testSignCert, Key: testSignPrivateKey}) + return b + }(), + }, + testSignCert + testSignPrivateKey, + false, + }, { "spew_sdump_simple_output", &NewTemplateInput{ @@ -2661,6 +2682,58 @@ eB01bl42Y5WwHl0IrjfbEevzoW0+uhlUlZ6keZHr7bLn/xuRCUkVfj3PRlMl -----END CERTIFICATE----- ` +const testSignCert = ` +-----BEGIN CERTIFICATE----- +MIIDXDCCAkSgAwIBAgIUDOLMfkSa8soQs1jdq//N96Du/J4wDQYJKoZIhvcNAQEL +BQAwLTErMCkGA1UEAxMiZXhhbXBsZS5jb20gSW50ZXJtZWRpYXRlIEF1dGhvcml0 +eTAeFw0yNDA0MDMxMTM4NTVaFw0yNDA1MDMxMTM5MjVaMBYxFDASBgNVBAMTC2V4 +YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAww7QC89+ +KKeZBsol2rlAVH/fUvnC0lJi5kpAWMYO+FzNerDHrXfaba4TUdQ9Aj/HonAfSCTM +f6WLP0s1K+eTvzpei+iopKwPjQVWUEs23YYSlBB+tN9m0cH7Csfzq4RA0JMD/ZHV +IjOjTAflmPEMCLhn8afFgk1fDadFL5PLL8Aa57qT3LaaAkRccE+CMo4dnqDjNJPY +H4EWsYx4SqkrQG+m6xTXNiHEpE9KXaApbWpMmgCeDZACkoLk2XRtfAaOk9mrbgrU +BQB5tottNlPHBRP7H/RCov2Z6yZVgv8P/h5xiAEcsoVbJvdZNasWLGlQ2RqIneIC +yFF1+RzlwthgyQIDAQABo4GKMIGHMA4GA1UdDwEB/wQEAwIDqDAdBgNVHSUEFjAU +BggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYEFGzZmJBb45E34LF+qHAZQkAt +chH4MB8GA1UdIwQYMBaAFIotMVSvUCx7z8O2xoxHF4THOLycMBYGA1UdEQQPMA2C +C2V4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQCn4Sdw7S+Pt3oZ7BIa0vBv +JHXZkvD3jOtN0/zGxZ4xFmpV8YZpp+lv/BIe1+s+B/mlK2oZb4ECibGe8Tr8vONN +kTOqjfWfC913bmDWK16PGrV9Uwe5TRXhtNLFyY9uoqyZzisJ0VJz+4pxaOiL7akG +R/bHUTHM7FxpKzhBLXuvZzwG0HIJQFq+h4XLv8iQ8UyLoigOInTecjtCeTftPBqw +QiMV0ol8ezs49D28MhdOb4N37XG5L2ZIxZzLeEFZmkmHvlXJoyBbOEACl4Ql9fXd +dshkFzxFdBaW8sAPreT9yEUlg7Qud9VOY7NQoWFTPJ+KE4wsFGzdxgj+fvsZrVGB +-----END CERTIFICATE----- +` +const testSignPrivateKey = ` +-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAww7QC89+KKeZBsol2rlAVH/fUvnC0lJi5kpAWMYO+FzNerDH +rXfaba4TUdQ9Aj/HonAfSCTMf6WLP0s1K+eTvzpei+iopKwPjQVWUEs23YYSlBB+ +tN9m0cH7Csfzq4RA0JMD/ZHVIjOjTAflmPEMCLhn8afFgk1fDadFL5PLL8Aa57qT +3LaaAkRccE+CMo4dnqDjNJPYH4EWsYx4SqkrQG+m6xTXNiHEpE9KXaApbWpMmgCe +DZACkoLk2XRtfAaOk9mrbgrUBQB5tottNlPHBRP7H/RCov2Z6yZVgv8P/h5xiAEc +soVbJvdZNasWLGlQ2RqIneICyFF1+RzlwthgyQIDAQABAoIBAQCf+GH/jag1x13l +B5yMCSoNIuIQtu1keFTL8VFcfPKCFfofCSR5y7XEBeOqVJnEYnJjcfj1vdhJR4cv +3Yo5+65cQo6Px7unccU/LoVfTJAulWpfLDf+NsmodaJhcSMSI2DUrf2z1AosBpWC +IWfXSrlH3ZTBx4pgFvxBwlEnd9pHyaJZ4Tu6dKa3j1fq0JcICeCsmsNdnqKSm8cT +bssw1+0EAWnYnkll7rIM919DT14XFu9ll0SQhUEN6hUBkGqhhqvl4S9K9dFPclYy +qEj3+UCoom2wHyCRen4o6wd2rKQpb/KdBAQCwXvAOrB3z4NEaqxcmWmyTpBi5z+W +PG/1j4gBAoGBANVZB1zHCLTHOHAK2kTEq6nP2QPnpVIFA8rPBT5E5fsqmPiP4IY6 +kh9HBhoA88rFXkB6W/7gs01PEUydAxQXrt+iX3DksMiuHwrtpA85tdD+zMaFKfO0 +wmh/4YFZhZEsF2Dvl3oLMxSjX3QXIDDQpYVCMbQWJhCnxVHGWYTbmU1BAoGBAOoN +uIrDEYzZF1GlkKp1yWoxmwFYKa3SGaWpOvfzzP2eAoK3wfn4wpJaLHUlQgjaqbpU +150F/NdQJ8r5EY4xyqx2wlp8X0NiS1pzooP0uA8gOyw1rZgHnN+rlRLL5CwgGgVt +JwGOi/2dCPbrwBfYo/52Ib8KgUWzYcb4bR7IpsmJAoGAI1SkAHxBd9aKBRv2+25q +UyvFb30cBpIoB5zy7FXyk/6A6KDC+NeYPS/A1euUc97tddYNiA7kAoh2f+58hQZL +AmPcVFC66fDT2TZzdcYD0wFvHe0NfntPuoh66rXNhbX8hSQIPMDAC8nmU85EmXDk +CEZm/sCwOw/dgGZNis/m+kECgYBwywH3JUCs7uXU/AP2keLp4VQA1trnIIwpkJ+R +ZJWSV3aARkwdyisCWqB4J+dl2vLWkBKEYqFRphg3McarDwXMDUNmVe+WyqTjxzw3 +eVTGPVMm4Atza5/HDqo9r7KbLTE9EjgtAOQn6Wirjjs5gratZ4KlzUs1KthhCdGU +d0AheQKBgFQdsm3j+f7b7orauSABSKgDI7f6vO6/tX3w4VoTPrdOPAdzOniKYUIn +BXyBNLIkUPZcW2llF1Oi7OzYOnai6xcbNdEbGcd1LL1opEVXZxOmdeCFQMdLRKrz +IcxtksB16A3xxxydRw3ENvHbZvjj1DHYJEGl0IXhkirnwMVn9hcE +-----END RSA PRIVATE KEY----- +` + func TestTemplate_ExtFuncMap(t *testing.T) { t.Parallel()