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 }}
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
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/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/README.md b/README.md
index 0be13dd..05b508d 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,27 @@
+
+# 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: Production - Ready for use in production environments.
+
+## 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.
+
+---
+
+
+---
+
+
+
@@ -30,4 +54,5 @@ The EJBCA CSR Signer v2.0 has breaking changes from v1.0. To migrate from v1.0 t
* [Runtime Customization](docs/annotations.markdown)
* [End Entity Name Selection](docs/endentitynamecustomization.markdown)
* [Testing](docs/testing.markdown)
-* [License](LICENSE)
\ No newline at end of file
+* [License](LICENSE)
+
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/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",
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
new file mode 100644
index 0000000..76a579f
--- /dev/null
+++ b/internal/signer/est/est.go
@@ -0,0 +1,329 @@
+/*
+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 (
+ "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,
+ errs: errs,
+ }
+}
+
+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..0bdf0ee
--- /dev/null
+++ b/internal/signer/est/est_test.go
@@ -0,0 +1,612 @@
+/*
+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 (
+ "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)
+ _, 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).
+ 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())
+ }
+
+ certs, err := client.SimpleEnroll(estAlias, 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_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()
+ 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)
+ _, 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()}).
+ 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.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_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 {
+ 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..0372f42
--- /dev/null
+++ b/internal/signer/est/server_test.go
@@ -0,0 +1,233 @@
+/*
+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 (
+ "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..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.
@@ -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
}
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.
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 @@
+
+
+
+
+
+
+
+
+# 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)