Browse Source

feat: add filterCertChain template helper function (#3934)

* feat: add filterCertChain template helper function

Signed-off-by: Sverre Boschman <1142569+sboschman@users.noreply.github.com>

* refactor: use constants for cert types

Signed-off-by: Sverre Boschman <1142569+sboschman@users.noreply.github.com>

* refactor: split TestFilterCertChain to reduce complexity

Signed-off-by: Sverre Boschman <1142569+sboschman@users.noreply.github.com>

* refactor: shortcut return in filterCertChain

Signed-off-by: Sverre Boschman <1142569+sboschman@users.noreply.github.com>

* refactor: root cert check in separate method

Signed-off-by: Sverre Boschman <1142569+sboschman@users.noreply.github.com>

---------

Signed-off-by: Sverre Boschman <1142569+sboschman@users.noreply.github.com>
Co-authored-by: Gergely Brautigam <182850+Skarlso@users.noreply.github.com>
Sverre Boschman 1 year ago
parent
commit
997cf24c2e

+ 7 - 0
docs/guides/templating.md

@@ -130,6 +130,12 @@ You can achieve that by using the `filterPEM` function to extract a specific typ
 {% include 'filterpem-template-v2-external-secret.yaml' %}
 ```
 
+In case you have a secret that contains a (partial) certificate chain you can extract the `leaf`, `intermediate` or `root` certificate(s) using the `filterCertChain` function. See the following example on how to use the `filterPEM` and `filterCertChain` functions together to split the certificate chain into a `tlc.crt` part only containting the leaf certificate and a `ca.crt` part with all the intermediate certificates.
+
+```yaml
+{% include 'filtercertchain-template-v2-external-secret.yaml' %}
+```
+
 ## Templating with PushSecret
 
 `PushSecret` templating is much like `ExternalSecrets` templating. In-fact under the hood, it's using the same data structure.
@@ -163,6 +169,7 @@ In addition to that you can use over 200+ [sprig functions](http://masterminds.g
 | fullPemToPkcs12      | Takes a PEM encoded certificates chain and key and creates a base64 encoded PKCS#12 archive.                                                                                                                                         |
 | fullPemToPkcs12Pass  | Same as `fullPemToPkcs12`. Uses the provided password to encrypt the PKCS#12 archive.                                                                                                                                            |
 | filterPEM        | Filters PEM blocks with a specific type from a list of PEM blocks.                                                                                                                                                           |
+| filterCertChain  | Filters PEM block(s) with a specific certificate type (`leaf`, `intermediate` or `root`)  from a certificate chain of PEM blocks (PEM blocks with type `CERTIFICATE`). |
 | jwkPublicKeyPem  | Takes an json-serialized JWK and returns an PEM block of type `PUBLIC KEY` that contains the public key. [See here](https://golang.org/pkg/crypto/x509/#MarshalPKIXPublicKey) for details.                                   |
 | jwkPrivateKeyPem | Takes an json-serialized JWK as `string` and returns an PEM block of type `PRIVATE KEY` that contains the private key in PKCS #8 format. [See here](https://golang.org/pkg/crypto/x509/#MarshalPKCS8PrivateKey) for details. |
 | toYaml           | Takes an interface, marshals it to yaml. It returns a string, even on marshal error (empty string).                                                                                                                          |

+ 17 - 0
docs/snippets/filtercertchain-template-v2-external-secret.yaml

@@ -0,0 +1,17 @@
+{% raw %}
+apiVersion: external-secrets.io/v1beta1
+kind: ExternalSecret
+metadata:
+  name: template
+spec:
+  # ...
+  target:
+    template:
+      type: kubernetes.io/tls
+      engineVersion: v2
+      data:
+        ca.crt: "{{ .mysecret | filterPEM "CERTIFICATE" | filterCertChain "intermediate" }}"
+        tls.crt: "{{ .mysecret | filterPEM "CERTIFICATE" | filterCertChain "leaf" }}"
+        tls.key: "{{ .mysecret | filterPEM "PRIVATE KEY" }}"
+
+{% endraw %}

+ 2 - 2
pkg/template/v2/jwk.go

@@ -34,7 +34,7 @@ func jwkPublicKeyPem(jwkjson string) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	return pemEncode(string(mpk), "PUBLIC KEY")
+	return pemEncode(mpk, "PUBLIC KEY")
 }
 
 func jwkPrivateKeyPem(jwkjson string) (string, error) {
@@ -52,5 +52,5 @@ func jwkPrivateKeyPem(jwkjson string) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	return pemEncode(string(mpk), "PRIVATE KEY")
+	return pemEncode(mpk, "PRIVATE KEY")
 }

+ 49 - 2
pkg/template/v2/pem.go

@@ -16,6 +16,7 @@ package template
 
 import (
 	"bytes"
+	"crypto/x509"
 	"encoding/pem"
 	"errors"
 	"strings"
@@ -23,6 +24,10 @@ import (
 
 const (
 	errJunk = "error filtering pem: found junk"
+
+	certTypeLeaf         = "leaf"
+	certTypeIntermediate = "intermediate"
+	certTypeRoot         = "root"
 )
 
 func filterPEM(pemType, input string) (string, error) {
@@ -56,8 +61,50 @@ func filterPEM(pemType, input string) (string, error) {
 	return string(blocks), nil
 }
 
-func pemEncode(thing, kind string) (string, error) {
+func filterCertChain(certType, input string) (string, error) {
+	ordered, err := fetchX509CertChains([]byte(input))
+	if err != nil {
+		return "", err
+	}
+
+	switch certType {
+	case certTypeLeaf:
+		cert := ordered[0]
+		if cert.AuthorityKeyId != nil && !bytes.Equal(cert.AuthorityKeyId, cert.SubjectKeyId) {
+			return pemEncode(ordered[0].Raw, pemTypeCertificate)
+		}
+	case certTypeIntermediate:
+		if len(ordered) < 2 {
+			return "", nil
+		}
+		var pemData []byte
+		for _, cert := range ordered[1:] {
+			if isRootCertificate(cert) {
+				break
+			}
+			b := &pem.Block{
+				Type:  pemTypeCertificate,
+				Bytes: cert.Raw,
+			}
+			pemData = append(pemData, pem.EncodeToMemory(b)...)
+		}
+		return string(pemData), nil
+	case certTypeRoot:
+		cert := ordered[len(ordered)-1]
+		if isRootCertificate(cert) {
+			return pemEncode(cert.Raw, pemTypeCertificate)
+		}
+	}
+
+	return "", nil
+}
+
+func isRootCertificate(cert *x509.Certificate) bool {
+	return cert.AuthorityKeyId == nil || bytes.Equal(cert.AuthorityKeyId, cert.SubjectKeyId)
+}
+
+func pemEncode(thing []byte, kind string) (string, error) {
 	buf := bytes.NewBuffer(nil)
-	err := pem.Encode(buf, &pem.Block{Type: kind, Bytes: []byte(thing)})
+	err := pem.Encode(buf, &pem.Block{Type: kind, Bytes: thing})
 	return buf.String(), err
 }

+ 10 - 3
pkg/template/v2/pem_chain.go

@@ -47,9 +47,8 @@ type node struct {
 	isParent bool
 }
 
-func fetchCertChains(data []byte) ([]byte, error) {
+func fetchX509CertChains(data []byte) ([]*x509.Certificate, error) {
 	var newCertChain []*x509.Certificate
-	var pemData []byte
 	nodes, err := pemToNodes(data)
 	if err != nil {
 		return nil, err
@@ -98,12 +97,20 @@ func fetchCertChains(data []byte) ([]byte, error) {
 		processedNodes++
 		// ensure we aren't stuck in a cyclic loop
 		if processedNodes > len(nodes) {
-			return pemData, errors.New(errChainCycle)
+			return nil, errors.New(errChainCycle)
 		}
 		newCertChain = append(newCertChain, leaf.cert)
 		leaf = leaf.parent
 	}
+	return newCertChain, nil
+}
 
+func fetchCertChains(data []byte) ([]byte, error) {
+	var pemData []byte
+	newCertChain, err := fetchX509CertChains(data)
+	if err != nil {
+		return nil, err
+	}
 	for _, cert := range newCertChain {
 		b := &pem.Block{
 			Type:  pemTypeCertificate,

+ 194 - 1
pkg/template/v2/pem_test.go

@@ -14,7 +14,10 @@ limitations under the License.
 
 package template
 
-import "testing"
+import (
+	"os"
+	"testing"
+)
 
 const (
 	certData = `-----BEGIN CERTIFICATE-----
@@ -179,3 +182,193 @@ func TestFilterPEM(t *testing.T) {
 		})
 	}
 }
+
+type filterCertChainTestArgs struct {
+	input    []string
+	certType string
+}
+
+type filterCertChainTest struct {
+	name    string
+	args    filterCertChainTestArgs
+	want    string
+	wantErr bool
+}
+
+func TestFilterCertChain(t *testing.T) {
+	const (
+		leafCertPath         = "_testdata/foo.crt"
+		intermediateCertPath = "_testdata/intermediate-ca.crt"
+		rootCertPath         = "_testdata/root-ca.crt"
+		rootKeyPath          = "_testdata/root-ca.key"
+	)
+	tests := []filterCertChainTest{
+		{
+			name: "extract leaf cert / empty cert chain",
+			args: filterCertChainTestArgs{
+				input:    []string{},
+				certType: certTypeLeaf,
+			},
+			wantErr: true,
+		},
+		{
+			name: "extract leaf cert / cert chain with pkey",
+			args: filterCertChainTestArgs{
+				input: []string{
+					leafCertPath,
+					rootKeyPath,
+				},
+				certType: certTypeLeaf,
+			},
+			wantErr: true,
+		},
+		{
+			name: "extract leaf cert / leaf cert only",
+			args: filterCertChainTestArgs{
+				input: []string{
+					leafCertPath,
+				},
+				certType: certTypeLeaf,
+			},
+			want: leafCertPath,
+		},
+		{
+			name: "extract leaf cert / cert chain without root",
+			args: filterCertChainTestArgs{
+				input: []string{
+					leafCertPath,
+					intermediateCertPath,
+				},
+				certType: certTypeLeaf,
+			},
+			want: leafCertPath,
+		},
+		{
+			name: "extract leaf cert / root cert only",
+			args: filterCertChainTestArgs{
+				input: []string{
+					rootCertPath,
+				},
+				certType: certTypeLeaf,
+			},
+			want: "",
+		},
+		{
+			name: "extract leaf cert / full cert chain",
+			args: filterCertChainTestArgs{
+				input: []string{
+					leafCertPath,
+					intermediateCertPath,
+					rootCertPath,
+				},
+				certType: certTypeLeaf,
+			},
+			want: leafCertPath,
+		},
+		{
+			name: "extract intermediate cert / leaf cert only",
+			args: filterCertChainTestArgs{
+				input: []string{
+					leafCertPath,
+				},
+				certType: certTypeIntermediate,
+			},
+			want: "",
+		},
+		{
+			name: "extract intermediate cert / cert chain without root",
+			args: filterCertChainTestArgs{
+				input: []string{
+					leafCertPath,
+					intermediateCertPath,
+				},
+				certType: certTypeIntermediate,
+			},
+			want: intermediateCertPath,
+		},
+		{
+			name: "extract intermediate cert / full cert chain",
+			args: filterCertChainTestArgs{
+				input: []string{
+					leafCertPath,
+					intermediateCertPath,
+					rootCertPath,
+				},
+				certType: certTypeIntermediate,
+			},
+			want: intermediateCertPath,
+		},
+		{
+			name: "extract root cert / leaf cert only",
+			args: filterCertChainTestArgs{
+				input: []string{
+					leafCertPath,
+				},
+				certType: certTypeRoot,
+			},
+			want: "",
+		},
+		{
+			name: "extract root cert / root cert only",
+			args: filterCertChainTestArgs{
+				input: []string{
+					rootCertPath,
+				},
+				certType: certTypeRoot,
+			},
+			want: rootCertPath,
+		},
+		{
+			name: "extract root cert / full cert chain",
+			args: filterCertChainTestArgs{
+				input: []string{
+					leafCertPath,
+					intermediateCertPath,
+					rootCertPath,
+				},
+				certType: certTypeRoot,
+			},
+			want: rootCertPath,
+		},
+	}
+	for _, tt := range tests {
+		runFilterCertChainTest(t, tt)
+	}
+}
+
+func runFilterCertChainTest(t *testing.T, tt filterCertChainTest) {
+	t.Run(tt.name, func(t *testing.T) {
+		chainIn, err := readCertificates(tt.args.input)
+		if err != nil {
+			t.Error(err)
+		}
+		var expOut []byte
+		if tt.want != "" {
+			var err error
+			expOut, err = os.ReadFile(tt.want)
+			if err != nil {
+				t.Error(err)
+			}
+		}
+		got, err := filterCertChain(tt.args.certType, string(chainIn))
+		if (err != nil) != tt.wantErr {
+			t.Errorf("filterCertChain() error = %v, wantErr %v", err, tt.wantErr)
+			return
+		}
+		if got != string(expOut) {
+			t.Errorf("filterCertChain() = %v, want %v", got, string(expOut))
+		}
+	})
+}
+
+func readCertificates(certFiles []string) ([]byte, error) {
+	var certificates []byte
+	for _, f := range certFiles {
+		c, err := os.ReadFile(f)
+		if err != nil {
+			return nil, err
+		}
+		certificates = append(certificates, c...)
+	}
+	return certificates, nil
+}

+ 2 - 1
pkg/template/v2/template.go

@@ -37,7 +37,8 @@ var tplFuncs = tpl.FuncMap{
 	"fullPemToPkcs12":     fullPemToPkcs12,
 	"fullPemToPkcs12Pass": fullPemToPkcs12Pass,
 
-	"filterPEM": filterPEM,
+	"filterPEM":       filterPEM,
+	"filterCertChain": filterCertChain,
 
 	"jwkPublicKeyPem":  jwkPublicKeyPem,
 	"jwkPrivateKeyPem": jwkPrivateKeyPem,