From f846dc1096cb7732e5e9f76c9dfb9f8f316e7cb3 Mon Sep 17 00:00:00 2001 From: Hayden Roszell Date: Mon, 12 Feb 2024 11:35:41 -0700 Subject: [PATCH 01/11] feat(est): Create generic EST client since EST client in ejbca-go-client is deprecated --- .gitignore | 3 +- go.mod | 3 +- go.sum | 5 - internal/signer/est/est.go | 328 ++++++++++++++++++++++++++++ internal/signer/est/est_test.go | 340 +++++++++++++++++++++++++++++ internal/signer/est/server_test.go | 217 ++++++++++++++++++ internal/signer/signer.go | 61 ++---- 7 files changed, 906 insertions(+), 51 deletions(-) create mode 100644 internal/signer/est/est.go create mode 100644 internal/signer/est/est_test.go create mode 100644 internal/signer/est/server_test.go diff --git a/.gitignore b/.gitignore index f71207d..4c1887f 100644 --- a/.gitignore +++ b/.gitignore @@ -369,4 +369,5 @@ FodyWeavers.xsd *.key credentials.yaml -vendor \ No newline at end of file +vendor +.env diff --git a/go.mod b/go.mod index aedcc7e..c816fbd 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,10 @@ module github.com/Keyfactor/ejbca-k8s-csr-signer go 1.20 require ( - github.com/Keyfactor/ejbca-go-client v1.3.7 github.com/Keyfactor/ejbca-go-client-sdk v0.1.5 github.com/go-logr/logr v1.3.0 github.com/stretchr/testify v1.8.4 + go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 k8s.io/api v0.28.4 k8s.io/apimachinery v0.28.4 k8s.io/client-go v0.28.4 @@ -49,7 +49,6 @@ require ( github.com/prometheus/common v0.45.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect github.com/spf13/pflag v1.0.5 // indirect - go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.26.0 // indirect golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect diff --git a/go.sum b/go.sum index 49ff71d..e10f250 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -github.com/Keyfactor/ejbca-go-client v1.3.7 h1:QhcBaR8O99ngG+zdRMYPsqFIoioc6tStq2zP2EuwNGU= -github.com/Keyfactor/ejbca-go-client v1.3.7/go.mod h1:onVifqcnxbIsYU/cEEYql3q8VbdhBlbzeH6I2MxPNFU= github.com/Keyfactor/ejbca-go-client-sdk v0.1.5 h1:PLX7NH6q26XyxIA7TQfZbKJawsXLZ+6yYs9pBYHsZrU= github.com/Keyfactor/ejbca-go-client-sdk v0.1.5/go.mod h1:12uc/cynQy/GEiYnYJgivFjRGpyusPvIu/vLYAscejs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -67,7 +65,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -198,8 +195,6 @@ k8s.io/component-base v0.28.4 h1:c/iQLWPdUgI90O+T9TeECg8o7N3YJTiuz2sKxILYcYo= k8s.io/component-base v0.28.4/go.mod h1:m9hR0uvqXDybiGL2nf/3Lf0MerAfQXzkfWhUY58JUbU= k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0= k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo= -k8s.io/kube-openapi v0.0.0-20231129212854-f0671cc7e66a h1:ZeIPbyHHqahGIbeyLJJjAUhnxCKqXaDY+n89Ms8szyA= -k8s.io/kube-openapi v0.0.0-20231129212854-f0671cc7e66a/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= k8s.io/kube-openapi v0.0.0-20231206194836-bf4651e18aa8 h1:vzKzxN5uyJZLY8HL1/OovW7BJefnsBIWt8T7Gjh2boQ= k8s.io/kube-openapi v0.0.0-20231206194836-bf4651e18aa8/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA= k8s.io/utils v0.0.0-20231127182322-b307cd553661 h1:FepOBzJ0GXm8t0su67ln2wAZjbQ6RxQGZDnzuLcrUTI= diff --git a/internal/signer/est/est.go b/internal/signer/est/est.go new file mode 100644 index 0000000..8b5e1bb --- /dev/null +++ b/internal/signer/est/est.go @@ -0,0 +1,328 @@ +/* +Copyright © 2023 Keyfactor + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package est + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/go-logr/logr" + "go.mozilla.org/pkcs7" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type Client interface { + CaCerts(alias string) ([]*x509.Certificate, error) + SimpleEnroll(alias string, csr string) ([]*x509.Certificate, error) +} + +type Builder struct { + ctx context.Context + logger logr.Logger + hostname string + client *http.Client + caCertificates []*x509.Certificate + clientCertificate *tls.Certificate + username string + password string + defaultESTAlias string + errs []error +} + +func NewBuilder(hostname string) *Builder { + var errs []error + + cleanHostname, err := cleanHostname(hostname) + if err != nil { + errs = append(errs, err) + } + + return &Builder{ + hostname: cleanHostname, + client: http.DefaultClient, + } +} + +func (b *Builder) WithClient(client *http.Client) *Builder { + b.client = client + return b +} + +// WithContext sets the context for the Builder +func (b *Builder) WithContext(ctx context.Context) *Builder { + b.ctx = ctx + b.logger = log.FromContext(ctx) + return b +} + +func (b *Builder) WithBasicAuth(username, password string) *Builder { + b.username = username + b.password = password + return b +} + +func (c *Builder) WithCaCertificates(caCertificates []*x509.Certificate) *Builder { + if caCertificates != nil { + c.caCertificates = caCertificates + } + + return c +} + +func (c *Builder) WithClientCertificate(clientCertificate *tls.Certificate) *Builder { + c.clientCertificate = clientCertificate + + return c +} + +func (b *Builder) WithDefaultESTAlias(alias string) *Builder { + b.defaultESTAlias = alias + return b +} + +func (b *Builder) Build() (Client, error) { + if b.hostname == "" { + return nil, fmt.Errorf("hostname is required") + } + + tlsConfig := &tls.Config{ + Renegotiation: tls.RenegotiateOnceAsClient, + } + + if b.clientCertificate != nil { + tlsConfig.Certificates = []tls.Certificate{*b.clientCertificate} + } + + if len(b.caCertificates) > 0 { + tlsConfig.RootCAs = x509.NewCertPool() + for _, caCert := range b.caCertificates { + tlsConfig.RootCAs.AddCert(caCert) + } + + tlsConfig.ClientCAs = tlsConfig.RootCAs + } + + customTransport := http.DefaultTransport.(*http.Transport).Clone() + customTransport.TLSClientConfig = tlsConfig + customTransport.TLSHandshakeTimeout = 10 * time.Second + + b.client.Transport = customTransport + + return &client{ + logger: b.logger, + hostname: b.hostname, + client: b.client, + basicAuthString: base64.StdEncoding.EncodeToString([]byte(b.username + ":" + b.password)), + defaultESTAlias: b.defaultESTAlias, + }, nil +} + +type client struct { + logger logr.Logger + hostname string + client *http.Client + basicAuthString string + defaultESTAlias string +} + +func (e *client) CaCerts(alias string) ([]*x509.Certificate, error) { + e.logger.Info("Getting CA certificate and chain with EST") + + // Endpoint in the form of //cacerts + endpoint := "" + if alias != "" { + endpoint = alias + "/" + } else if e.defaultESTAlias != "" { + endpoint = e.defaultESTAlias + "/" + } + endpoint += "cacerts" + + url := fmt.Sprintf("https://%s/.well-known/est/%s", e.hostname, endpoint) + + request, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + request.Header.Set("Accept", "application/pkcs7-mime") + request.Header.Set("Accept-Encoding", "base64") + + // No authentication necessary to get the CA certificates + + e.logger.Info(fmt.Sprintf("Prepared a GET request to the CaCerts endpoint: %s", url)) + + getCaCertsRestResponse, err := e.client.Do(request) + if err != nil { + return nil, err + } + + if getCaCertsRestResponse.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", getCaCertsRestResponse.StatusCode) + } + + // Ensure that we got a pkcs7 mime + content, ok := getCaCertsRestResponse.Header["Content-Type"] + if !ok || len(content) == 0 || !strings.Contains(content[0], "application/pkcs7-mime") { + errMsg := "unknown or empty content-type" + if len(content) > 0 { + errMsg = fmt.Sprintf("unexpected content-type: %s", content[0]) + } + return nil, fmt.Errorf(errMsg) + } + + // Ensure that the response is base64 encoded + encoding, ok := getCaCertsRestResponse.Header["Content-Transfer-Encoding"] + if !ok || len(encoding) == 0 || encoding[0] != "base64" { + errMsg := "unknown or empty content-transfer-encoding" + if len(encoding) > 0 { + errMsg = fmt.Sprintf("unexpected content-transfer-encoding: %s", encoding[0]) + } + return nil, fmt.Errorf(errMsg) + } + + e.logger.Info("Validated HTTP response headers") + + e.logger.Info("Decoding PKCS#7 mime") + + encodedBytes, err := io.ReadAll(getCaCertsRestResponse.Body) + if err != nil { + return nil, err + } + + decodedBytes, err := base64.StdEncoding.DecodeString(string(encodedBytes)) + if err != nil { + return nil, err + } + + parsed, err := pkcs7.Parse(decodedBytes) + if err != nil { + return nil, err + } + + e.logger.Info(fmt.Sprintf("Found %d certificates in chain", len(parsed.Certificates))) + + return parsed.Certificates, nil +} + +// SimpleEnroll uses the EJBCA EST endpoint with an optional alias to perform a simple CSR enrollment. +// * alias - optional EJBCA EST alias +// * csr - Base64 encoded PKCS#10 CSR +func (e *client) SimpleEnroll(alias string, csr string) ([]*x509.Certificate, error) { + e.logger.Info("Performing a simple CSR enrollment with EST") + + endpoint := "" + if alias != "" { + // Use alias passed as argument, if provided + endpoint = alias + "/" + } else if e.defaultESTAlias != "" { + // If not provided, use the default alias, if it exists + endpoint = e.defaultESTAlias + "/" + } + endpoint += "simpleenroll" + + url := fmt.Sprintf("https://%s/.well-known/est/%s", e.hostname, endpoint) + + request, err := http.NewRequest("POST", url, strings.NewReader(csr)) + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Basic "+e.basicAuthString) + request.Header.Set("Content-Type", "application/pkcs10") + request.Header.Set("Content-Transfer-Encoding", "base64") + request.Header.Set("Accept", "application/pkcs7-mime") + request.Header.Set("Accept-Encoding", "base64") + + e.logger.Info(fmt.Sprintf("Prepared a POST request to the SimpleEnroll endpoint: %s", url)) + + simpleEnrollRestResponse, err := e.client.Do(request) + if err != nil { + return nil, err + } + defer simpleEnrollRestResponse.Body.Close() + + // Ensure that we got a pkcs7 mime + content, ok := simpleEnrollRestResponse.Header["Content-Type"] + if !ok || len(content) == 0 || !strings.Contains(content[0], "application/pkcs7-mime") { + errMsg := "unknown or empty content-type" + if len(content) > 0 { + errMsg = fmt.Sprintf("unexpected content-type: %s", content[0]) + } + return nil, fmt.Errorf(errMsg) + } + + // Ensure that the response is base64 encoded + encoding, ok := simpleEnrollRestResponse.Header["Content-Transfer-Encoding"] + if !ok || len(encoding) == 0 || encoding[0] != "base64" { + errMsg := "unknown or empty content-transfer-encoding" + if len(encoding) > 0 { + errMsg = fmt.Sprintf("unexpected content-transfer-encoding: %s", encoding[0]) + } + return nil, fmt.Errorf(errMsg) + } + + e.logger.Info("Validated HTTP response headers") + + // TODO if Content-Transfer-Encoding is not set, we should assume 7bit + + e.logger.Info("Decoding PKCS#7 mime") + + encodedBytes, err := io.ReadAll(simpleEnrollRestResponse.Body) + if err != nil { + return nil, err + } + + decodedBytes, err := base64.StdEncoding.DecodeString(string(encodedBytes)) + if err != nil { + return nil, fmt.Errorf("failed to decode PKCS#7 response from EST server: %s", err) + } + + parsed, err := pkcs7.Parse(decodedBytes) + if err != nil { + return nil, err + } + + e.logger.Info(fmt.Sprintf("Found %d certificates in chain", len(parsed.Certificates))) + + return parsed.Certificates, nil +} + +func cleanHostname(hostname string) (string, error) { + if hostname == "" { + return "", errors.New("hostname cannot be empty") + } + + // When parsing a hostname without a scheme, Go will assume it is a path. + if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") { + hostname = "https://" + hostname + } + + if u, err := url.Parse(hostname); err == nil { + return u.Host, nil + } else { + return "", fmt.Errorf("EJBCA hostname is not a valid URL: %s", err) + } +} diff --git a/internal/signer/est/est_test.go b/internal/signer/est/est_test.go new file mode 100644 index 0000000..20c116b --- /dev/null +++ b/internal/signer/est/est_test.go @@ -0,0 +1,340 @@ +/* +Copyright © 2023 Keyfactor + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package est + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "encoding/pem" + "fmt" + "log" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + logrtesting "github.com/go-logr/logr/testr" + "go.mozilla.org/pkcs7" + ctrl "sigs.k8s.io/controller-runtime" +) + +func TestClient_SimpleEnrollSuccess(t *testing.T) { + username := "user" + password := "password" + estAlias := "testAlias" + + cert, err := generateSelfSignedCertificate() + if err != nil { + t.Fatalf("failed to generate self-signed certificate: %s", err.Error()) + } + + simpleEnrollResponder := func(w http.ResponseWriter, r *http.Request) { + t.Logf("Request: %v", r) + + if r.URL.Path != fmt.Sprintf("/.well-known/est/%s/simpleenroll", estAlias) { + t.Fatalf("Expected URL path to be /.well-known/%s/est/simpleenroll, got %s", estAlias, r.URL.Path) + } + + if r.Header.Get("Content-Type") != "application/pkcs10" { + t.Fatalf("Expected Content-Type to be application/pkcs10, got %s", r.Header.Get("Content-Type")) + } + + if r.Header.Get("Content-Transfer-Encoding") != "base64" { + t.Fatalf("Expected Content-Transfer-Encoding to be base64, got %s", r.Header.Get("Content-Transfer-Encoding")) + } + + b64AuthString := r.Header.Get("Authorization") + authString, err := base64.StdEncoding.DecodeString(b64AuthString[6:]) + if err != nil { + t.Fatalf("Failed to decode base64 auth string: %s", err.Error()) + } + + if string(authString) != fmt.Sprintf("%s:%s", username, password) { + t.Fatalf("Expected Authorization header to be %s:%s, got %s", username, password, string(authString)) + } + + t.Logf("SimpleEnroll request validated successfully") + + b64Pkcs7 := exportCertificateToB64Pkcs7(cert) + + w.Header().Set("Content-Type", "application/pkcs7-mime") + w.Header().Set("Content-Transfer-Encoding", "base64") + w.WriteHeader(200) + w.Write(b64Pkcs7) + } + + testServer := httptest.NewTLSServer(http.HandlerFunc(simpleEnrollResponder)) + defer testServer.Close() + + ctx := ctrl.LoggerInto(context.TODO(), logrtesting.New(t)) + + client, err := NewBuilder(testServer.URL). + WithContext(ctx). + WithClient(http.DefaultClient). + WithCaCertificates([]*x509.Certificate{testServer.Certificate()}). + WithBasicAuth(username, password). + WithDefaultESTAlias(estAlias). + Build() + if err != nil { + t.Fatalf("failed to create client: %s", err.Error()) + } + + csr, _, err := generateCSR("CN=test.com", []string{}, []string{}, []string{}) + + certs, err := client.SimpleEnroll(estAlias, string(csr)) + if err != nil { + t.Fatal(err) + } + + if len(certs) != 1 { + t.Fatal(fmt.Sprintf("Expected SimpleEnroll to return exactly 1 certificate - got back %d", len(certs))) + } + + if certs[0].Subject.CommonName != cert.Subject.CommonName { + t.Fatalf("Expected CommonName to be %s, got %s", cert.Subject.CommonName, certs[0].Subject.CommonName) + } + + if certs[0].SerialNumber.Cmp(cert.SerialNumber) != 0 { + t.Fatalf("Expected SerialNumber to be %s, got %s", cert.SerialNumber, certs[0].SerialNumber) + } +} + +func TestClient_CaCerts(t *testing.T) { + estAlias := "testAlias" + + cert, err := generateSelfSignedCertificate() + if err != nil { + t.Fatalf("failed to generate self-signed certificate: %s", err.Error()) + } + + caCertsResponder := func(w http.ResponseWriter, r *http.Request) { + t.Logf("Request: %v", r) + + if r.URL.Path != fmt.Sprintf("/.well-known/est/%s/cacerts", estAlias) { + t.Fatalf("Expected URL path to be /.well-known/%s/est/cacerts, got %s", estAlias, r.URL.Path) + } + + t.Logf("CaCerts request validated successfully") + + b64Pkcs7 := exportCertificateToB64Pkcs7(cert) + + w.Header().Set("Content-Type", "application/pkcs7-mime") + w.Header().Set("Content-Transfer-Encoding", "base64") + w.WriteHeader(200) + w.Write(b64Pkcs7) + } + + testServer := httptest.NewTLSServer(http.HandlerFunc(caCertsResponder)) + defer testServer.Close() + + ctx := ctrl.LoggerInto(context.TODO(), logrtesting.New(t)) + + client, err := NewBuilder(testServer.URL). + WithContext(ctx). + WithClient(http.DefaultClient). + WithCaCertificates([]*x509.Certificate{testServer.Certificate()}). + WithDefaultESTAlias(estAlias). + Build() + if err != nil { + t.Fatalf("failed to create client: %s", err.Error()) + } + + certs, err := client.CaCerts(estAlias) + if err != nil { + t.Fatal(err) + } + + if len(certs) != 1 { + t.Fatal(fmt.Sprintf("Expected CaCerts to return exactly 1 certificate - got back %d", len(certs))) + } + + if certs[0].Subject.CommonName != cert.Subject.CommonName { + t.Fatalf("Expected CommonName to be %s, got %s", cert.Subject.CommonName, certs[0].Subject.CommonName) + } + + if certs[0].SerialNumber.Cmp(cert.SerialNumber) != 0 { + t.Fatalf("Expected SerialNumber to be %s, got %s", cert.SerialNumber, certs[0].SerialNumber) + } +} + +func generateSelfSignedCertificate() (*x509.Certificate, error) { + priv, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, err + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, err + } + + cert, err := x509.ParseCertificate(certDER) + if err != nil { + return nil, err + } + + return cert, nil +} + +func exportCertificateToB64Pkcs7(cert *x509.Certificate) []byte { + signedData, err := pkcs7.NewSignedData([]byte{}) + if err != nil { + log.Fatalf("Failed to create SignedData: %v", err) + } + + signedData.AddCertificate(cert) + + signedData.Detach() + + derBytes, err := signedData.Finish() + if err != nil { + log.Fatalf("Failed to serialize the SignedData: %v", err) + } + + base64Str := base64.StdEncoding.EncodeToString(derBytes) + + return []byte(base64Str) +} + +func generateCSR(subject string, dnsNames []string, uris []string, ipAddresses []string) ([]byte, *x509.CertificateRequest, error) { + keyBytes, _ := rsa.GenerateKey(rand.Reader, 2048) + + subj, err := parseSubjectDN(subject) + if err != nil { + return nil, nil, err + } + + template := x509.CertificateRequest{ + Subject: subj, + SignatureAlgorithm: x509.SHA256WithRSA, + } + + if len(dnsNames) > 0 { + template.DNSNames = dnsNames + } + + // Parse and add URIs + var uriPointers []*url.URL + for _, u := range uris { + if u == "" { + continue + } + uriPointer, err := url.Parse(u) + if err != nil { + return nil, nil, err + } + uriPointers = append(uriPointers, uriPointer) + } + template.URIs = uriPointers + + // Parse and add IPAddresses + var ipAddrs []net.IP + for _, ipStr := range ipAddresses { + if ipStr == "" { + continue + } + ip := net.ParseIP(ipStr) + if ip == nil { + return nil, nil, fmt.Errorf("invalid IP address: %s", ipStr) + } + ipAddrs = append(ipAddrs, ip) + } + template.IPAddresses = ipAddrs + + // Generate the CSR + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &template, keyBytes) + if err != nil { + return nil, nil, err + } + + var csrBuf bytes.Buffer + err = pem.Encode(&csrBuf, &pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrBytes}) + if err != nil { + return nil, nil, err + } + + parsedCSR, err := x509.ParseCertificateRequest(csrBytes) + if err != nil { + return nil, nil, err + } + + return csrBuf.Bytes(), parsedCSR, nil +} + +// Function that turns subject string into pkix.Name +// EG "C=US,ST=California,L=San Francisco,O=HashiCorp,OU=Engineering,CN=example.com" +func parseSubjectDN(subject string) (pkix.Name, error) { + var name pkix.Name + + if subject == "" { + return name, nil + } + + // Split the subject into its individual parts + parts := strings.Split(subject, ",") + + for _, part := range parts { + // Split the part into key and value + keyValue := strings.SplitN(part, "=", 2) + + if len(keyValue) != 2 { + return pkix.Name{}, asn1.SyntaxError{Msg: "malformed subject DN"} + } + + key := strings.TrimSpace(keyValue[0]) + value := strings.TrimSpace(keyValue[1]) + + // Map the key to the appropriate field in the pkix.Name struct + switch key { + case "C": + name.Country = []string{value} + case "ST": + name.Province = []string{value} + case "L": + name.Locality = []string{value} + case "O": + name.Organization = []string{value} + case "OU": + name.OrganizationalUnit = []string{value} + case "CN": + name.CommonName = value + default: + // Ignore any unknown keys + } + } + + return name, nil +} diff --git a/internal/signer/est/server_test.go b/internal/signer/est/server_test.go new file mode 100644 index 0000000..b7676c5 --- /dev/null +++ b/internal/signer/est/server_test.go @@ -0,0 +1,217 @@ +package est + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "log" + "math/big" + "net/http" + "os" + "strings" + "time" +) + +type route struct { + path string + handler func(w http.ResponseWriter, r *http.Request) +} + +type Server struct { + ctx context.Context + + srv *http.Server + address string + + tlsCert, tlsKey string + + // routes is a slice of routes that contain path patterns and a corresponding handler. + routes []*route + basePath string + + // patterns is an internal tracking map to quickly determine if there are duplicates in routes. + patterns map[string]string +} + +func NewServer(ctx context.Context) *Server { + log.Printf("creating new server service") + + server := &Server{ + routes: make([]*route, 0), + ctx: ctx, + patterns: make(map[string]string), + } + + return server +} + +func (s *Server) WithAddress(address string) *Server { + log.Printf("using address [%s]", address) + + s.address = address + return s +} + +func (s *Server) WithTLSCertificate(cert, key string) *Server { + _, certErr := os.Stat(cert) + _, keyErr := os.Stat(key) + if os.IsNotExist(certErr) || os.IsNotExist(keyErr) { + log.Fatalf("TLS cert [%s] or key [%s] does not exist", cert, key) + } + + log.Printf("using TLS cert [%s] and key [%s]", cert, key) + + s.tlsCert = cert + s.tlsKey = key + return s +} + +func (s *Server) WithSelfSignedTLSCertificate() error { + log.Printf("generating self-signed TLS certificate") + + // Generate a new private key + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + // Set up a certificate template + notBefore := time.Now() + notAfter := notBefore.Add(365 * 24 * time.Hour) + serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Boilerplate Organization"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + // Self-sign the certificate + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey) + if err != nil { + return err + } + + // Export the certificate and private key to disk + certOut, err := os.Create("cert.pem") + if err != nil { + return err + } + if err := pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return err + } + if err := certOut.Close(); err != nil { + return err + } + + keyOut, err := os.OpenFile("key.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) + if err != nil { + return err + } + if err := pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}); err != nil { + return err + } + if err := keyOut.Close(); err != nil { + return err + } + + s.tlsCert = "cert.pem" + s.tlsKey = "key.pem" + + return nil +} + +func (s *Server) WithBasePath(basePath string) *Server { + log.Printf("using base path [%s]", basePath) + + if !strings.HasPrefix(basePath, "/") { + basePath = fmt.Sprintf("/%s", basePath) + } + + s.basePath = basePath + return s +} + +func (s *Server) AddRoute(path string, routeHandler func(w http.ResponseWriter, r *http.Request)) *Server { + if !strings.HasPrefix(path, "/") { + path = fmt.Sprintf("/%s", path) + } + + pattern, ok := s.patterns[path] + if ok { + log.Printf("duplicate route pattern found [%s]", pattern) + + return s + } + + log.Printf("adding route [%s]\n", path) + + s.patterns[path] = path + s.routes = append(s.routes, &route{ + path: fmt.Sprintf("%s%s", s.basePath, path), + handler: routeHandler, + }) + + return s +} + +func (s *Server) Start() { + log.Printf("starting server service") + mux := http.NewServeMux() + + for _, route := range s.routes { + mux.HandleFunc(route.path, route.handler) + } + + s.srv = &http.Server{ + Addr: s.address, + Handler: mux, + } + + serveTlsService := s.tlsCert != "" && s.tlsKey != "" + + go func() { + log.Printf("starting REST server on address [%s]\n", s.address) + if serveTlsService { + if err := s.srv.ListenAndServeTLS(s.tlsCert, s.tlsKey); err != nil && !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + } else { + if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + panic(err) + } + } + }() +} + +// Shutdown shuts down the http server +func (s *Server) Shutdown() { + done := make(chan struct{}) + go func() { + defer close(done) + if err := s.srv.Shutdown(s.ctx); err != nil { + log.Printf("couldn't shutdown server: %s\n", err) + } + }() + + select { + case <-done: + log.Println("server service shutdown complete") + case <-s.ctx.Done(): + log.Println("service canceled") + } +} diff --git a/internal/signer/signer.go b/internal/signer/signer.go index bac089c..361b23c 100644 --- a/internal/signer/signer.go +++ b/internal/signer/signer.go @@ -24,17 +24,18 @@ import ( "encoding/pem" "errors" "fmt" + "math/rand" + "net/http" + "strconv" + "github.com/Keyfactor/ejbca-go-client-sdk/api/ejbca" - ejbcaest "github.com/Keyfactor/ejbca-go-client/pkg/ejbca" + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/signer/est" "github.com/Keyfactor/ejbca-k8s-csr-signer/pkg/util" "github.com/go-logr/logr" certificates "k8s.io/api/certificates/v1" corev1 "k8s.io/api/core/v1" utilerrors "k8s.io/apimachinery/pkg/util/errors" - "math/rand" - "os" "sigs.k8s.io/controller-runtime/pkg/log" - "strconv" ) // ejbcaSigner implements both Signer and Builder interfaces @@ -77,7 +78,7 @@ type ejbcaSigner struct { caChain []*x509.Certificate preflightComplete bool - estClient *ejbcaest.Client + estClient est.Client restClient *ejbca.APIClient } @@ -326,7 +327,7 @@ func (s *ejbcaSigner) newRestClient() (*ejbca.APIClient, error) { // newEstClient creates a new EJBCA EST API client using the EJBCA Go Client. // It sets up the client to use HTTP Basic Auth with the username and password from the credentials secret -func (s *ejbcaSigner) newEstClient() (*ejbcaest.Client, error) { +func (s *ejbcaSigner) newEstClient() (est.Client, error) { // Get username and password from secret username, ok := s.creds.Data["username"] if !ok { @@ -338,40 +339,18 @@ func (s *ejbcaSigner) newEstClient() (*ejbcaest.Client, error) { return nil, errors.New("password not found in secret data") } - ejbcaConfig := &ejbcaest.Config{ - DefaultESTAlias: s.defaultESTAlias, - } - - // Copy the root CAs to a file on the filesystem - if len(s.caChain) > 0 { - bytes, err := util.CompileCertificatesToPemBytes(s.caChain) - if err != nil { - s.logger.Error(err, "Failed to compile CA certificates to PEM bytes") - return nil, err - } - err = os.WriteFile("/tmp/cacerts.pem", bytes, 0644) - if err != nil { - return nil, err - } - - ejbcaConfig.CAFile = "/tmp/cacerts.pem" - } - - ejbcaFactory, err := ejbcaest.ClientFactory(s.hostname, ejbcaConfig) + client, err := est.NewBuilder(s.hostname). + WithContext(s.ctx). + WithClient(http.DefaultClient). + WithCaCertificates(s.caChain). + WithBasicAuth(string(username), string(password)). + WithDefaultESTAlias(s.defaultESTAlias). + Build() if err != nil { - s.logger.Error(err, "Failed to create EJBCA EST client factory") - return nil, err + return nil, fmt.Errorf("Error creating EST client: %s", err) } - s.logger.Info("Creating EJBCA EST client") - - ejbcaClient, err := ejbcaFactory.NewESTClient(string(username), string(password)) - if err != nil { - s.logger.Error(err, "Failed to create EJBCA EST client") - return nil, err - } - - return ejbcaClient, nil + return client, nil } // Build builds the Signer from the Builder, but secretly returns the Builder since it implements @@ -693,18 +672,14 @@ func (s *ejbcaSigner) signWithEst(csr *certificates.CertificateSigningRequest) ( // Decode PEM encoded PKCS#10 CSR to DER block, _ := pem.Decode(csr.Spec.Request) - if s.estClient.EST == nil { - return nil, errors.New("est client is nil - configuration error likely") - } - // Enroll CSR with simpleenroll - leaf, err := s.estClient.EST.SimpleEnroll(alias, base64.StdEncoding.EncodeToString(block.Bytes)) + leaf, err := s.estClient.SimpleEnroll(alias, base64.StdEncoding.EncodeToString(block.Bytes)) if err != nil { return nil, err } // Grab the CA chain of trust from cacerts - chain, err := s.estClient.EST.CaCerts(alias) + chain, err := s.estClient.CaCerts(alias) if err != nil { return nil, err } From 61f9d9d9848e55ed98936c5d7d1d6db0d59f0053 Mon Sep 17 00:00:00 2001 From: Hayden Roszell Date: Mon, 12 Feb 2024 17:00:36 -0700 Subject: [PATCH 02/11] chore(tests): Implement more tests for signer EST client --- CHANGELOG.md | 8 +- internal/signer/est/est.go | 1 + internal/signer/est/est_test.go | 282 +++++++++++++++++++++++++++++++- 3 files changed, 285 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eba5cc..57be349 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# v2.1.0 +## Features + +### Signer +- Implemented in-project EST client to remove EJBCA Go Client as dependency + # v2.0.0 ## Features @@ -19,4 +25,4 @@ ### Actions - Added GitHub Actions for building and testing the EJBCA CSR Signer -- Added GitHub Actions for releasing the EJBCA CSR Signer \ No newline at end of file +- Added GitHub Actions for releasing the EJBCA CSR Signer diff --git a/internal/signer/est/est.go b/internal/signer/est/est.go index 8b5e1bb..b77f01c 100644 --- a/internal/signer/est/est.go +++ b/internal/signer/est/est.go @@ -63,6 +63,7 @@ func NewBuilder(hostname string) *Builder { return &Builder{ hostname: cleanHostname, client: http.DefaultClient, + errs: errs, } } diff --git a/internal/signer/est/est_test.go b/internal/signer/est/est_test.go index 20c116b..bb0146b 100644 --- a/internal/signer/est/est_test.go +++ b/internal/signer/est/est_test.go @@ -84,7 +84,10 @@ func TestClient_SimpleEnrollSuccess(t *testing.T) { w.Header().Set("Content-Type", "application/pkcs7-mime") w.Header().Set("Content-Transfer-Encoding", "base64") w.WriteHeader(200) - w.Write(b64Pkcs7) + _, err = w.Write(b64Pkcs7) + if err != nil { + t.Fatalf("Failed to write response: %v", err) + } } testServer := httptest.NewTLSServer(http.HandlerFunc(simpleEnrollResponder)) @@ -104,6 +107,9 @@ func TestClient_SimpleEnrollSuccess(t *testing.T) { } csr, _, err := generateCSR("CN=test.com", []string{}, []string{}, []string{}) + if err != nil { + t.Fatalf("failed to generate CSR: %s", err.Error()) + } certs, err := client.SimpleEnroll(estAlias, string(csr)) if err != nil { @@ -111,7 +117,7 @@ func TestClient_SimpleEnrollSuccess(t *testing.T) { } if len(certs) != 1 { - t.Fatal(fmt.Sprintf("Expected SimpleEnroll to return exactly 1 certificate - got back %d", len(certs))) + t.Fatalf("Expected SimpleEnroll to return exactly 1 certificate - got back %d", len(certs)) } if certs[0].Subject.CommonName != cert.Subject.CommonName { @@ -123,7 +129,156 @@ func TestClient_SimpleEnrollSuccess(t *testing.T) { } } -func TestClient_CaCerts(t *testing.T) { +func TestClient_SimpleEnrollNoAliasSuccess(t *testing.T) { + username := "user" + password := "password" + + cert, err := generateSelfSignedCertificate() + if err != nil { + t.Fatalf("failed to generate self-signed certificate: %s", err.Error()) + } + + simpleEnrollResponder := func(w http.ResponseWriter, r *http.Request) { + t.Logf("Request: %v", r) + + if r.URL.Path != "/.well-known/est/simpleenroll" { + t.Fatalf("Expected URL path to be /.well-known/est/simpleenroll, got %s", r.URL.Path) + } + + if r.Header.Get("Content-Type") != "application/pkcs10" { + t.Fatalf("Expected Content-Type to be application/pkcs10, got %s", r.Header.Get("Content-Type")) + } + + if r.Header.Get("Content-Transfer-Encoding") != "base64" { + t.Fatalf("Expected Content-Transfer-Encoding to be base64, got %s", r.Header.Get("Content-Transfer-Encoding")) + } + + b64AuthString := r.Header.Get("Authorization") + authString, err := base64.StdEncoding.DecodeString(b64AuthString[6:]) + if err != nil { + t.Fatalf("Failed to decode base64 auth string: %s", err.Error()) + } + + if string(authString) != fmt.Sprintf("%s:%s", username, password) { + t.Fatalf("Expected Authorization header to be %s:%s, got %s", username, password, string(authString)) + } + + t.Logf("SimpleEnroll request validated successfully") + + b64Pkcs7 := exportCertificateToB64Pkcs7(cert) + + w.Header().Set("Content-Type", "application/pkcs7-mime") + w.Header().Set("Content-Transfer-Encoding", "base64") + w.WriteHeader(200) + _, err = w.Write(b64Pkcs7) + if err != nil { + t.Fatalf("Failed to write response: %v", err) + } + } + + testServer := httptest.NewTLSServer(http.HandlerFunc(simpleEnrollResponder)) + defer testServer.Close() + + ctx := ctrl.LoggerInto(context.TODO(), logrtesting.New(t)) + + client, err := NewBuilder(testServer.URL). + WithContext(ctx). + WithClient(http.DefaultClient). + WithCaCertificates([]*x509.Certificate{testServer.Certificate()}). + WithBasicAuth(username, password). + Build() + if err != nil { + t.Fatalf("failed to create client: %s", err.Error()) + } + + csr, _, err := generateCSR("CN=test.com", []string{}, []string{}, []string{}) + if err != nil { + t.Fatalf("failed to generate CSR: %s", err.Error()) + } + + certs, err := client.SimpleEnroll("", string(csr)) + if err != nil { + t.Fatal(err) + } + + if len(certs) != 1 { + t.Fatalf("Expected SimpleEnroll to return exactly 1 certificate - got back %d", len(certs)) + } + + if certs[0].Subject.CommonName != cert.Subject.CommonName { + t.Fatalf("Expected CommonName to be %s, got %s", cert.Subject.CommonName, certs[0].Subject.CommonName) + } + + if certs[0].SerialNumber.Cmp(cert.SerialNumber) != 0 { + t.Fatalf("Expected SerialNumber to be %s, got %s", cert.SerialNumber, certs[0].SerialNumber) + } +} + +func TestClient_SimpleEnrollFailure(t *testing.T) { + username := "user" + password := "password" + estAlias := "testAlias" + + testCases := []struct { + name string + handlerFunc func(w http.ResponseWriter, r *http.Request) + expectedError error + }{ + { + name: "InvalidContentType", + handlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + }, + expectedError: fmt.Errorf("unexpected content-type: application/json"), + }, + { + name: "InvalidContentTransferEncoding", + handlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/pkcs7-mime") + w.Header().Set("Content-Transfer-Encoding", "binary") + w.WriteHeader(200) + }, + expectedError: fmt.Errorf("unexpected content-transfer-encoding: binary"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + testServer := httptest.NewTLSServer(http.HandlerFunc(tc.handlerFunc)) + defer testServer.Close() + + ctx := ctrl.LoggerInto(context.TODO(), logrtesting.New(t)) + + client, err := NewBuilder(testServer.URL). + WithContext(ctx). + WithClient(http.DefaultClient). + WithCaCertificates([]*x509.Certificate{testServer.Certificate()}). + WithBasicAuth(username, password). + WithDefaultESTAlias(estAlias). + Build() + if err != nil { + t.Fatalf("failed to create client: %s", err.Error()) + } + + csr, _, err := generateCSR("CN=test.com", []string{}, []string{}, []string{}) + if err != nil { + t.Fatalf("failed to generate CSR: %s", err.Error()) + } + + _, err = client.SimpleEnroll(estAlias, string(csr)) + if err == nil { + t.Fatal("Expected SimpleEnroll to return an error") + } + + if err.Error() != tc.expectedError.Error() { + t.Fatalf("Expected error to be %q, got %q", tc.expectedError.Error(), err.Error()) + } + }) + } +} + +func TestClient_CaCertsSuccess(t *testing.T) { estAlias := "testAlias" cert, err := generateSelfSignedCertificate() @@ -145,7 +300,10 @@ func TestClient_CaCerts(t *testing.T) { w.Header().Set("Content-Type", "application/pkcs7-mime") w.Header().Set("Content-Transfer-Encoding", "base64") w.WriteHeader(200) - w.Write(b64Pkcs7) + _, err = w.Write(b64Pkcs7) + if err != nil { + t.Fatalf("Failed to write response: %v", err) + } } testServer := httptest.NewTLSServer(http.HandlerFunc(caCertsResponder)) @@ -169,7 +327,7 @@ func TestClient_CaCerts(t *testing.T) { } if len(certs) != 1 { - t.Fatal(fmt.Sprintf("Expected CaCerts to return exactly 1 certificate - got back %d", len(certs))) + t.Fatalf("Expected CaCerts to return exactly 1 certificate - got back %d", len(certs)) } if certs[0].Subject.CommonName != cert.Subject.CommonName { @@ -181,6 +339,120 @@ func TestClient_CaCerts(t *testing.T) { } } +func TestClient_CaCertsNoAliasSuccess(t *testing.T) { + cert, err := generateSelfSignedCertificate() + if err != nil { + t.Fatalf("failed to generate self-signed certificate: %s", err.Error()) + } + + caCertsResponder := func(w http.ResponseWriter, r *http.Request) { + t.Logf("Request: %v", r) + + if r.URL.Path != "/.well-known/est/cacerts" { + t.Fatalf("Expected URL path to be /.well-known/est/cacerts, got %s", r.URL.Path) + } + + t.Logf("CaCerts request validated successfully") + + b64Pkcs7 := exportCertificateToB64Pkcs7(cert) + + w.Header().Set("Content-Type", "application/pkcs7-mime") + w.Header().Set("Content-Transfer-Encoding", "base64") + w.WriteHeader(200) + _, err = w.Write(b64Pkcs7) + if err != nil { + t.Fatalf("Failed to write response: %v", err) + } + } + + testServer := httptest.NewTLSServer(http.HandlerFunc(caCertsResponder)) + defer testServer.Close() + + ctx := ctrl.LoggerInto(context.TODO(), logrtesting.New(t)) + + client, err := NewBuilder(testServer.URL). + WithContext(ctx). + WithClient(http.DefaultClient). + WithCaCertificates([]*x509.Certificate{testServer.Certificate()}). + Build() + if err != nil { + t.Fatalf("failed to create client: %s", err.Error()) + } + + certs, err := client.CaCerts("") + if err != nil { + t.Fatal(err) + } + + if len(certs) != 1 { + t.Fatalf("Expected CaCerts to return exactly 1 certificate - got back %d", len(certs)) + } + + if certs[0].Subject.CommonName != cert.Subject.CommonName { + t.Fatalf("Expected CommonName to be %s, got %s", cert.Subject.CommonName, certs[0].Subject.CommonName) + } + + if certs[0].SerialNumber.Cmp(cert.SerialNumber) != 0 { + t.Fatalf("Expected SerialNumber to be %s, got %s", cert.SerialNumber, certs[0].SerialNumber) + } +} + +func TestClient_CaCertsFailure(t *testing.T) { + estAlias := "testAlias" + + testCases := []struct { + name string + handlerFunc func(w http.ResponseWriter, r *http.Request) + expectedError error + }{ + { + name: "InvalidContentType", + handlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + }, + expectedError: fmt.Errorf("unexpected content-type: application/json"), + }, + { + name: "InvalidContentTransferEncoding", + handlerFunc: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/pkcs7-mime") + w.Header().Set("Content-Transfer-Encoding", "binary") + w.WriteHeader(200) + }, + expectedError: fmt.Errorf("unexpected content-transfer-encoding: binary"), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + testServer := httptest.NewTLSServer(http.HandlerFunc(tc.handlerFunc)) + defer testServer.Close() + + ctx := ctrl.LoggerInto(context.TODO(), logrtesting.New(t)) + + client, err := NewBuilder(testServer.URL). + WithContext(ctx). + WithClient(http.DefaultClient). + WithCaCertificates([]*x509.Certificate{testServer.Certificate()}). + WithDefaultESTAlias(estAlias). + Build() + if err != nil { + t.Fatalf("failed to create client: %s", err.Error()) + } + + _, err = client.CaCerts(estAlias) + if err == nil { + t.Fatal("Expected SimpleEnroll to return an error") + } + + if err.Error() != tc.expectedError.Error() { + t.Fatalf("Expected error to be %q, got %q", tc.expectedError.Error(), err.Error()) + } + }) + } +} + func generateSelfSignedCertificate() (*x509.Certificate, error) { priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { From d1ea393923cbaeb8e0ff0089f79429fbae5dceb5 Mon Sep 17 00:00:00 2001 From: Hayden Roszell Date: Mon, 12 Feb 2024 17:09:52 -0700 Subject: [PATCH 03/11] chore(license): Update Keyfactor license header --- .../certificatesigningrequest_controller.go | 2 +- .../certificatesigningrequest_controller_test.go | 2 +- internal/controllers/fake_configclient_test.go | 2 +- internal/controllers/fake_signer_test.go | 2 +- internal/signer/est/est.go | 2 +- internal/signer/est/est_test.go | 2 +- internal/signer/est/server_test.go | 16 ++++++++++++++++ internal/signer/signer.go | 2 +- internal/signer/signer_test.go | 2 +- main.go | 3 +-- pkg/util/configclient.go | 2 +- pkg/util/configclient_test.go | 2 +- pkg/util/util.go | 2 +- pkg/util/util_test.go | 2 +- 14 files changed, 29 insertions(+), 14 deletions(-) diff --git a/internal/controllers/certificatesigningrequest_controller.go b/internal/controllers/certificatesigningrequest_controller.go index 6d16dce..7337f0b 100644 --- a/internal/controllers/certificatesigningrequest_controller.go +++ b/internal/controllers/certificatesigningrequest_controller.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/controllers/certificatesigningrequest_controller_test.go b/internal/controllers/certificatesigningrequest_controller_test.go index 874518f..d3ecb40 100644 --- a/internal/controllers/certificatesigningrequest_controller_test.go +++ b/internal/controllers/certificatesigningrequest_controller_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/controllers/fake_configclient_test.go b/internal/controllers/fake_configclient_test.go index 97c04c3..413722c 100644 --- a/internal/controllers/fake_configclient_test.go +++ b/internal/controllers/fake_configclient_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/controllers/fake_signer_test.go b/internal/controllers/fake_signer_test.go index 2b3d918..1cc68b0 100644 --- a/internal/controllers/fake_signer_test.go +++ b/internal/controllers/fake_signer_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/signer/est/est.go b/internal/signer/est/est.go index b77f01c..76a579f 100644 --- a/internal/signer/est/est.go +++ b/internal/signer/est/est.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/signer/est/est_test.go b/internal/signer/est/est_test.go index bb0146b..0bdf0ee 100644 --- a/internal/signer/est/est_test.go +++ b/internal/signer/est/est_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/signer/est/server_test.go b/internal/signer/est/server_test.go index b7676c5..0372f42 100644 --- a/internal/signer/est/server_test.go +++ b/internal/signer/est/server_test.go @@ -1,3 +1,19 @@ +/* +Copyright © 2024 Keyfactor + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + package est import ( diff --git a/internal/signer/signer.go b/internal/signer/signer.go index 361b23c..a479191 100644 --- a/internal/signer/signer.go +++ b/internal/signer/signer.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/internal/signer/signer_test.go b/internal/signer/signer_test.go index 95587ba..e368a93 100644 --- a/internal/signer/signer_test.go +++ b/internal/signer/signer_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/main.go b/main.go index 0bfa098..fcd12d8 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,5 @@ /* -Copyright © 2023 Keyfactor - +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at diff --git a/pkg/util/configclient.go b/pkg/util/configclient.go index e6782f7..d3dc586 100644 --- a/pkg/util/configclient.go +++ b/pkg/util/configclient.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/pkg/util/configclient_test.go b/pkg/util/configclient_test.go index 5c30aad..c96e6a5 100644 --- a/pkg/util/configclient_test.go +++ b/pkg/util/configclient_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/pkg/util/util.go b/pkg/util/util.go index 6c8e858..38b6f6e 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go index 29c7367..8c06372 100644 --- a/pkg/util/util_test.go +++ b/pkg/util/util_test.go @@ -1,5 +1,5 @@ /* -Copyright © 2023 Keyfactor +Copyright © 2024 Keyfactor Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. From d92e15e10072d6bae3fc69308b618e77cd005114 Mon Sep 17 00:00:00 2001 From: Hayden Roszell Date: Mon, 12 Feb 2024 17:14:05 -0700 Subject: [PATCH 04/11] chore(docs): Migrate README.md to readme_source.md --- README.md | 33 --------------------------------- readme_source.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 33 deletions(-) create mode 100644 readme_source.md diff --git a/README.md b/README.md index 0be13dd..e69de29 100644 --- a/README.md +++ b/README.md @@ -1,33 +0,0 @@ - - Kubernetes logo - - - - Helm logo - - -# EJBCA Certificate Signing Request Proxy for K8s - -[![Go Report Card](https://goreportcard.com/badge/github.com/Keyfactor/ejbca-k8s-csr-signer)](https://goreportcard.com/report/github.com/Keyfactor/ejbca-k8s-csr-signer) [![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/keyfactor/ejbca-k8s-csr-signer?label=release)](https://github.com/keyfactor/ejbca-k8s-csr-signer/releases) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) [![license](https://img.shields.io/github/license/keyfactor/ejbca-k8s-csr-signer.svg)]() - -The EJBCA Certificate Signing Request Proxy for K8s forwards certificate signing requests generated by Kubernetes to [EJBCA](https://www.primekey.com/products/ejbca-enterprise/) for signing by a trusted enterprise certificate authority. The signer operates within the [K8s CertificateSigningRequests API](https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/) and implements a Controller that uses the the V1 CertificateSigningRequests informer to handle associated resources. CSRs are only enrolled if they are approved using an [approver](https://github.com/kubernetes/kubernetes/tree/master/pkg/controller/certificates/approver). - -## Community supported -We welcome contributions. - -The cert-manager external issuer for Keyfactor command is open source and community supported, meaning that there is **no SLA** applicable for these tools. - -###### To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, see the [contribution guidelines](https://github.com/Keyfactor/command-k8s-csr-signer/blob/main/CONTRIBUTING.md) and use the **[Pull requests](../../pulls)** tab. - -## Migration from EJBCA CSR Signer v1.0 to v2.0 - -The EJBCA CSR Signer v2.0 has breaking changes from v1.0. To migrate from v1.0 to v2.0, uninstall the v1.0 deployment and install the v2.0 deployment. The v2.0 deployment uses the same configuration as v1.0, but the configuration is now stored in a Kubernetes ConfigMap. See the [Getting Started](docs/getting-started.markdown) to install the v2.0 deployment. - -## Documentation -* [Getting Started](docs/getting-started.markdown) -* Usage - * [Demo usage with Istio](docs/istio-deployment.markdown) - * [Runtime Customization](docs/annotations.markdown) - * [End Entity Name Selection](docs/endentitynamecustomization.markdown) -* [Testing](docs/testing.markdown) -* [License](LICENSE) \ No newline at end of file diff --git a/readme_source.md b/readme_source.md new file mode 100644 index 0000000..2061c34 --- /dev/null +++ b/readme_source.md @@ -0,0 +1,33 @@ + + Kubernetes logo + + + + Helm logo + + +# EJBCA Certificate Signing Request Proxy for K8s + +[![Go Report Card](https://goreportcard.com/badge/github.com/Keyfactor/ejbca-k8s-csr-signer)](https://goreportcard.com/report/github.com/Keyfactor/ejbca-k8s-csr-signer) [![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/keyfactor/ejbca-k8s-csr-signer?label=release)](https://github.com/keyfactor/ejbca-k8s-csr-signer/releases) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) [![license](https://img.shields.io/github/license/keyfactor/ejbca-k8s-csr-signer.svg)]() + +The EJBCA Certificate Signing Request Proxy for K8s forwards certificate signing requests generated by Kubernetes to [EJBCA](https://www.primekey.com/products/ejbca-enterprise/) for signing by a trusted enterprise certificate authority. The signer operates within the [K8s CertificateSigningRequests API](https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/) and implements a Controller that uses the the V1 CertificateSigningRequests informer to handle associated resources. CSRs are only enrolled if they are approved using an [approver](https://github.com/kubernetes/kubernetes/tree/master/pkg/controller/certificates/approver). + +## Community supported +We welcome contributions. + +The cert-manager external issuer for Keyfactor command is open source and community supported, meaning that there is **no SLA** applicable for these tools. + +###### To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, see the [contribution guidelines](https://github.com/Keyfactor/command-k8s-csr-signer/blob/main/CONTRIBUTING.md) and use the **[Pull requests](../../pulls)** tab. + +## Migration from EJBCA CSR Signer v1.0 to v2.0 + +The EJBCA CSR Signer v2.0 has breaking changes from v1.0. To migrate from v1.0 to v2.0, uninstall the v1.0 deployment and install the v2.0 deployment. The v2.0 deployment uses the same configuration as v1.0, but the configuration is now stored in a Kubernetes ConfigMap. See the [Getting Started](docs/getting-started.markdown) to install the v2.0 deployment. + +## Documentation +* [Getting Started](docs/getting-started.markdown) +* Usage + * [Demo usage with Istio](docs/istio-deployment.markdown) + * [Runtime Customization](docs/annotations.markdown) + * [End Entity Name Selection](docs/endentitynamecustomization.markdown) +* [Testing](docs/testing.markdown) +* [License](LICENSE) From 744395c0a0425a8d95d1fe5889d5d885abbf67da Mon Sep 17 00:00:00 2001 From: Hayden Roszell Date: Mon, 12 Feb 2024 17:17:59 -0700 Subject: [PATCH 05/11] chore(ci): Add Keyfactor Bootstrap workflow --- .github/workflows/keyfactor-workflow.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/keyfactor-workflow.yml diff --git a/.github/workflows/keyfactor-workflow.yml b/.github/workflows/keyfactor-workflow.yml new file mode 100644 index 0000000..d62baa6 --- /dev/null +++ b/.github/workflows/keyfactor-workflow.yml @@ -0,0 +1,20 @@ +# Also called the Bootstrap Workflow +name: Keyfactor Workflow + +on: + workflow_dispatch: + pull_request: + types: [opened, closed, synchronize, edited, reopened] + push: + create: + branches: + - 'release-*.*' + +jobs: + call-starter-workflow: + uses: keyfactor/actions/.github/workflows/starter.yml@v2 + secrets: + token: ${{ secrets.V2BUILDTOKEN}} + APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} + gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} + gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} From dfba8d9972ab00395489efb89001906893e00cf3 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 13 Feb 2024 00:18:27 +0000 Subject: [PATCH 06/11] Update generated README --- README.md | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/README.md b/README.md index e69de29..21a4bb4 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,58 @@ + +# ejbca-k8s-csr-signer + +An implementation of the Kubernetes CSR signing API that routes Certificate Signing Requests from the cluster to the EJBCA Enrollment API + +#### Integration status: Pilot - Ready for use in test environments. Not for use in production. + +## About the Keyfactor API Client + +This API client allows for programmatic management of Keyfactor resources. + +## Support for ejbca-k8s-csr-signer + +ejbca-k8s-csr-signer is open source and supported on best effort level for this tool/library/client. This means customers can report Bugs, Feature Requests, Documentation amendment or questions as well as requests for customer information required for setup that needs Keyfactor access to obtain. Such requests do not follow normal SLA commitments for response or resolution. If you have a support issue, please open a support ticket via the Keyfactor Support Portal at https://support.keyfactor.com/ + +###### To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, use the **[Pull requests](../../pulls)** tab. + +--- + + +--- + + + + + Kubernetes logo + + + + Helm logo + + +# EJBCA Certificate Signing Request Proxy for K8s + +[![Go Report Card](https://goreportcard.com/badge/github.com/Keyfactor/ejbca-k8s-csr-signer)](https://goreportcard.com/report/github.com/Keyfactor/ejbca-k8s-csr-signer) [![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/keyfactor/ejbca-k8s-csr-signer?label=release)](https://github.com/keyfactor/ejbca-k8s-csr-signer/releases) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) [![license](https://img.shields.io/github/license/keyfactor/ejbca-k8s-csr-signer.svg)]() + +The EJBCA Certificate Signing Request Proxy for K8s forwards certificate signing requests generated by Kubernetes to [EJBCA](https://www.primekey.com/products/ejbca-enterprise/) for signing by a trusted enterprise certificate authority. The signer operates within the [K8s CertificateSigningRequests API](https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/) and implements a Controller that uses the the V1 CertificateSigningRequests informer to handle associated resources. CSRs are only enrolled if they are approved using an [approver](https://github.com/kubernetes/kubernetes/tree/master/pkg/controller/certificates/approver). + +## Community supported +We welcome contributions. + +The cert-manager external issuer for Keyfactor command is open source and community supported, meaning that there is **no SLA** applicable for these tools. + +###### To report a problem or suggest a new feature, use the **[Issues](../../issues)** tab. If you want to contribute actual bug fixes or proposed enhancements, see the [contribution guidelines](https://github.com/Keyfactor/command-k8s-csr-signer/blob/main/CONTRIBUTING.md) and use the **[Pull requests](../../pulls)** tab. + +## Migration from EJBCA CSR Signer v1.0 to v2.0 + +The EJBCA CSR Signer v2.0 has breaking changes from v1.0. To migrate from v1.0 to v2.0, uninstall the v1.0 deployment and install the v2.0 deployment. The v2.0 deployment uses the same configuration as v1.0, but the configuration is now stored in a Kubernetes ConfigMap. See the [Getting Started](docs/getting-started.markdown) to install the v2.0 deployment. + +## Documentation +* [Getting Started](docs/getting-started.markdown) +* Usage + * [Demo usage with Istio](docs/istio-deployment.markdown) + * [Runtime Customization](docs/annotations.markdown) + * [End Entity Name Selection](docs/endentitynamecustomization.markdown) +* [Testing](docs/testing.markdown) +* [License](LICENSE) + From 6f3d4580b3330552e966e79345c2c22f0c423e8a Mon Sep 17 00:00:00 2001 From: Hayden Roszell Date: Mon, 12 Feb 2024 17:26:16 -0700 Subject: [PATCH 07/11] chore(manifest): Update integration manifest --- integration-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-manifest.json b/integration-manifest.json index 51874af..791958d 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -2,7 +2,7 @@ "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", "integration_type": "api-client", "name": "ejbca-k8s-csr-signer", - "status": "pilot", + "status": "production", "link_github": true, "description": "An implementation of the Kubernetes CSR signing API that routes Certificate Signing Requests from the cluster to the EJBCA Enrollment API", "support_level": "kf-community", From e763cdf8ce22d0c34fd6597be21207b08c766a53 Mon Sep 17 00:00:00 2001 From: Keyfactor Date: Tue, 13 Feb 2024 00:26:44 +0000 Subject: [PATCH 08/11] Update generated README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 21a4bb4..05b508d 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ An implementation of the Kubernetes CSR signing API that routes Certificate Signing Requests from the cluster to the EJBCA Enrollment API -#### Integration status: Pilot - Ready for use in test environments. Not for use in production. +#### Integration status: Production - Ready for use in production environments. ## About the Keyfactor API Client From 992d79d9e0046623a8dc8805019b20e9492e9558 Mon Sep 17 00:00:00 2001 From: Hayden Roszell Date: Mon, 12 Feb 2024 17:50:44 -0700 Subject: [PATCH 09/11] chore(ci): Force zero at the end of Helm release --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a2c5a37..863b98d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: # Set version from DOCKER_METADATA_OUTPUT_VERSION as environment variable - name: Set Version run: | - echo "VERSION=${DOCKER_METADATA_OUTPUT_VERSION:8}" >> $GITHUB_ENV + echo "VERSION=${DOCKER_METADATA_OUTPUT_VERSION:8}.0" >> $GITHUB_ENV # Eventually will build this into Keyfactor bootstrap # Change version and appVersion in Chart.yaml to the tag in the closed PR - name: Update Helm App/Chart Version From dfd513a7c08de746037e750abcec09fb35f3319e Mon Sep 17 00:00:00 2001 From: Hayden Roszell Date: Mon, 12 Feb 2024 18:05:18 -0700 Subject: [PATCH 10/11] chore(manifest): Add build matrix to integration manifest --- integration-manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/integration-manifest.json b/integration-manifest.json index 791958d..6cfc688 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -4,6 +4,7 @@ "name": "ejbca-k8s-csr-signer", "status": "production", "link_github": true, + "platform_matrix": "linux/arm64,linux/amd64,linux/s390x,linux/ppc64le" "description": "An implementation of the Kubernetes CSR signing API that routes Certificate Signing Requests from the cluster to the EJBCA Enrollment API", "support_level": "kf-community", "release_dir": "" From edb4ceb2c3e8a561532c66a74937f108f1f2116b Mon Sep 17 00:00:00 2001 From: Hayden Roszell Date: Mon, 12 Feb 2024 18:06:32 -0700 Subject: [PATCH 11/11] fix(manifest): Typo in JSON formatting --- integration-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration-manifest.json b/integration-manifest.json index 6cfc688..2f51cb5 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -4,7 +4,7 @@ "name": "ejbca-k8s-csr-signer", "status": "production", "link_github": true, - "platform_matrix": "linux/arm64,linux/amd64,linux/s390x,linux/ppc64le" + "platform_matrix": "linux/arm64,linux/amd64,linux/s390x,linux/ppc64le", "description": "An implementation of the Kubernetes CSR signing API that routes Certificate Signing Requests from the cluster to the EJBCA Enrollment API", "support_level": "kf-community", "release_dir": ""