diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4eeeab3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 +updates: + - package-ecosystem: 'gomod' + # Raise pull requests for version updates + # against the `main` branch + target-branch: "main" + directory: '/' + schedule: + interval: 'weekly' + - package-ecosystem: 'github-actions' + # Raise pull requests for version updates + # against the `main` branch + target-branch: "main" + directory: '/' + schedule: + interval: 'weekly' diff --git a/.github/workflows/keyfactor-bootstrap-workflow.yml b/.github/workflows/keyfactor-bootstrap-workflow.yml new file mode 100644 index 0000000..4c8b002 --- /dev/null +++ b/.github/workflows/keyfactor-bootstrap-workflow.yml @@ -0,0 +1,84 @@ +name: Keyfactor Bootstrap Workflow + +on: + workflow_dispatch: + pull_request: + types: [opened, closed, synchronize, edited, reopened] + push: + create: + branches: + - 'release-*.*' + +jobs: + build: + name: Build, Lint, and Test + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Checkout code + # https://github.com/actions/checkout + - name: Checkout code + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + + # Setup GoLang build environment + # https://github.com/actions/setup-go + - name: Set up Go 1.x + uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version-file: 'go.mod' + cache: true + + # Download dependencies + - run: go mod download + + # Build Go binary + - run: go build -v . + + # Run Go linters + # https://github.com/golangci/golangci-lint-action + - name: Run linters + uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6.1.0 + with: + version: latest + + # Run Go tests + - name: Run go test + run: go test -v ./... + + integrationtest: + name: Integration Test + needs: build + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + # Checkout code + # https://github.com/actions/checkout + - name: Checkout code + uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 + + # Create a single-node K8s cluster with Kind + # Then, deploy an ephemeral EJBCA and SignServer + - uses: m8rmclaren/ejbca-signserver-k8s@main + with: + deploy-k8s: 'true' + deploy-nginx-ingress: 'true' + deploy-signserver: 'false' + + # Run integration test + - name: Run integration test + run: | + chmod +x test/integrationtest.sh + ./test/integrationtest.sh + + call-starter-workflow: + uses: keyfactor/actions/.github/workflows/starter.yml@v3 + needs: integrationtest + secrets: + token: ${{ secrets.V2BUILDTOKEN}} + APPROVE_README_PUSH: ${{ secrets.APPROVE_README_PUSH}} + gpg_key: ${{ secrets.KF_GPG_PRIVATE_KEY }} + gpg_pass: ${{ secrets.KF_GPG_PASSPHRASE }} + scan_token: ${{ secrets.SAST_TOKEN }} + docker-user: ${{ secrets.DOCKER_USER }} + docker-token: ${{ secrets.DOCKER_PWD }} + diff --git a/.github/workflows/keyfactor-workflow.yml b/.github/workflows/keyfactor-workflow.yml deleted file mode 100644 index d62baa6..0000000 --- a/.github/workflows/keyfactor-workflow.yml +++ /dev/null @@ -1,20 +0,0 @@ -# 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/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 36b7a72..0000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,76 +0,0 @@ -name: test -on: [workflow_dispatch, push, pull_request] -jobs: - build: - name: Build and Lint - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # Checkout code - # https://github.com/actions/checkout - - name: Checkout code - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - # Setup GoLang build environment - # https://github.com/actions/setup-go - - name: Set up Go 1.x - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version-file: 'go.mod' - cache: true - - # Download dependencies - - run: go mod download - - # Build Go binary - - run: go build -v . - - # Run Go linters - # https://github.com/golangci/golangci-lint-action - - name: Run linters - uses: golangci/golangci-lint-action@3a919529898de77ec3da873e3063ca4b10e7f5cc # v3.7.0 - with: - version: latest - - test: - name: Go Test - needs: build - runs-on: ubuntu-latest - timeout-minutes: 5 - steps: - # Checkout code - # https://github.com/actions/checkout - - name: Checkout code - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0 - - # Setup GoLang build environment - # https://github.com/actions/setup-go - - name: Set up Go 1.x - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 - with: - go-version-file: 'go.mod' - cache: true - - # Download dependencies - - run: go mod download - - # Place the contents of ${{ secrets.EJBCA_CLIENT_CERT_PATH }} into a file at /tmp/certs/ejbca.pem - - run: mkdir -p /tmp/certs && echo "${{ secrets.EJBCA_CLIENT_CERT }}" > /tmp/certs/ejbca.pem - - # Place the contents of ${{ secrets.EJBCA_CA_CERT_PATH }} into a file at /tmp/certs/ejbca-ca.pem - - run: mkdir -p /tmp/certs && echo "${{ secrets.EJBCA_CA_CERT }}" > /tmp/certs/ejbca-ca.pem - - # Run Go tests - - name: Run go test - run: go test -v ./... - env: - EJBCA_CLIENT_CERT_PATH: /tmp/certs/ejbca.pem - EJBCA_CA_CERT_PATH: /tmp/certs/ejbca-ca.pem - EJBCA_CA_NAME: ${{ vars.EJBCA_CA_NAME }} - EJBCA_HOSTNAME: ${{ secrets.EJBCA_HOSTNAME }} - EJBCA_CERTIFICATE_PROFILE_NAME: ${{ vars.EJBCA_CERTIFICATE_PROFILE_NAME }} - EJBCA_CSR_SUBJECT: ${{ vars.EJBCA_CSR_SUBJECT }} - EJBCA_END_ENTITY_PROFILE_NAME: ${{ vars.EJBCA_END_ENTITY_PROFILE_NAME }} - EJBCA_EST_ALIAS: ${{ vars.EJBCA_EST_ALIAS }} - EJBCA_EST_PASSWORD: ${{ secrets.EJBCA_EST_PASSWORD }} - EJBCA_EST_USERNAME: ${{ secrets.EJBCA_EST_USERNAME }} \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..856c6c3 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,32 @@ +run: + # timeout for analysis, e.g. 30s, 5m, default is 1m + timeout: 12m + + skip-dirs: + - testdata$ + - test/mock + + skip-files: + - ".*\\.pb\\.go" + +linters: + enable: + - bodyclose + - durationcheck + - errorlint + - goimports + - revive + - gosec + - misspell + - nakedret + - unconvert + - unparam + - whitespace + - gocritic + - nolintlint + - govet + +linters-settings: + revive: + # minimal confidence for issues, default is 0.8 + confidence: 0.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 57be349..2a84fdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +# 2.2.0 +## Features + +### Auth +- Implement OAuth 2.0 Client Credentials grant as supported auth mechanism + +### Testing +- Refactor unit tests to use fake interfaces and extract integration tests to a shell script that interacts with K8s directly + # v2.1.0 ## Features diff --git a/Dockerfile b/Dockerfile index de13ed5..0b0c079 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM golang:1.20 as builder +FROM golang:1.22.3 as builder ARG TARGETOS ARG TARGETARCH @@ -13,7 +13,6 @@ RUN go mod download # Copy the go source COPY main.go main.go -COPY pkg/ pkg/ COPY internal/ internal/ # Build diff --git a/README.md b/README.md index 46d22ef..3d41695 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,3 @@ - -# 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. - ---- - - ---- - - - Kubernetes logo @@ -30,24 +6,32 @@ ejbca-k8s-csr-signer is open source and supported on best effort level for this 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)]() + +# EJBCA K8s CSR Signer + +![Integration Status: production](https://img.shields.io/badge/integration_status-production-3D1973?style=flat-square) +[![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)]() + + + +## Overview 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. +## Requirements -## Migration from EJBCA CSR Signer v1.0 to v2.0 +TODO Requirements is a required section -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 + * [Getting Started](docs/getting-started.markdown) * Usage * [Demo usage with Istio](docs/istio-deployment.markdown) @@ -56,4 +40,25 @@ The EJBCA CSR Signer v2.0 has breaking changes from v1.0. To migrate from v1.0 t * [Testing](docs/testing.markdown) * [License](LICENSE) +### 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. + + + +## Community Support + +In the [Keyfactor Community](https://www.keyfactor.com/community/), we welcome contributions. Keyfactor Community software is open-source and community-supported, meaning that **no SLA** is applicable. Keyfactor will address issues as resources become available. + +* To report a problem or suggest a new feature, go to [Issues](../../issues). +* If you want to contribute bug fixes or proposed enhancements, see the [Contributing Guidelines](CONTRIBUTING.md) and create a [Pull request](../../pulls). + +## Commercial Support + +Commercial support is available for [EJBCA Enterprise](https://www.keyfactor.com/products/ejbca-enterprise/). + +## License +For license information, see [LICENSE](LICENSE). +## Related Projects +See all [Keyfactor EJBCA GitHub projects](https://github.com/orgs/Keyfactor/repositories?q=ejbca). \ No newline at end of file diff --git a/docs/getting-started.markdown b/docs/getting-started.markdown index e864ba3..9f9cc34 100644 --- a/docs/getting-started.markdown +++ b/docs/getting-started.markdown @@ -44,18 +44,43 @@ make docker-build DOCKER_REGISTRY= DOCKER_IMAGE_NAME=ke 2. The EJCBA K8s CSR Signer can enroll certificates using the EJBCA REST API and with EST. - * If you want to configure the signer to enroll certificates using the EJBCA REST API (IE EST is not configured), authentication to the EJBCA API is handled using client certificates. - - Create a `kubernetes.io/tls` secret containing the client certificate and key. The secret must be created in the same namespace as the CSR proxy. + * **EJBCA REST API** + + If you want to configure the signer to enroll certificates using the EJBCA REST API (IE EST is not configured), authentication to the EJBCA API is available using client certificate (mTLS) or the OAuth 2.0 Client Credentials Grant. + + * **mTLS** + + Create a `kubernetes.io/tls` secret containing the client certificate and key. The secret must be created in the same namespace as the CSR proxy. + + ```shell + kubectl create secret tls ejbca-credentials \ + --namespace ejbca-signer-system \ + --cert= \ + --key= + ``` + + * **OAuth 2.0 Client Credentials Grant** - ```shell - kubectl create secret tls ejbca-credentials \ - --namespace ejbca-signer-system \ - --cert= \ - --key= - ``` - - * If you want to configure the signer to enroll certificates using EST, authentication to the EJBCA API is handled using HTTP Basic Authentication. + Create an `Opaque` secret containing the client ID and client secret. The secret must be created in the same namespace as the CSR proxy. + + ```shell + token_url="" + client_id="" + client_secret="" + audience="" + scopes="" + + kubectl -n ejbca-issuer-system create secret generic ejbca-secret \ + "--from-literal=tokenUrl=$token_url" \ + "--from-literal=clientId=$client_id" \ + "--from-literal=clientSecret=$client_secret" \ + "--from-literal=audience=$audience" \ + "--from-literal=scopes=$scopes" + ``` + + * **EST** + + If you want to configure the signer to enroll certificates using EST, authentication to the EJBCA API is handled using HTTP Basic Authentication. Create a `kubernetes.io/basic-auth` secret containing the username and password. The secret must be created in the same namespace as the CSR proxy. diff --git a/readme_source.md b/docsource/overview.md similarity index 62% rename from readme_source.md rename to docsource/overview.md index 2061c34..d361e48 100644 --- a/readme_source.md +++ b/docsource/overview.md @@ -1,29 +1,13 @@ - - 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)]() +## Overview 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. +## Requirements -The cert-manager external issuer for Keyfactor command is open source and community supported, meaning that there is **no SLA** applicable for these tools. +TODO Requirements is a required section -###### 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. +## Getting Started -## 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) @@ -31,3 +15,25 @@ The EJBCA CSR Signer v2.0 has breaking changes from v1.0. To migrate from v1.0 t * [End Entity Name Selection](docs/endentitynamecustomization.markdown) * [Testing](docs/testing.markdown) * [License](LICENSE) + +### 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. + +## Logos + + + Kubernetes logo + + + + Helm logo + + +## Badges + +[![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)]() + diff --git a/go.mod b/go.mod index c816fbd..9aee71d 100644 --- a/go.mod +++ b/go.mod @@ -1,72 +1,79 @@ module github.com/Keyfactor/ejbca-k8s-csr-signer -go 1.20 +go 1.22.3 require ( - 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 + github.com/Keyfactor/ejbca-go-client-sdk v1.0.2 + github.com/cert-manager/cert-manager v1.15.2 + github.com/go-logr/logr v1.4.1 + github.com/stretchr/testify v1.9.0 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 - k8s.io/klog/v2 v2.110.1 - k8s.io/utils v0.0.0-20231127182322-b307cd553661 - sigs.k8s.io/controller-runtime v0.16.3 + k8s.io/api v0.30.1 + k8s.io/apimachinery v0.30.1 + k8s.io/client-go v0.30.1 + k8s.io/klog/v2 v2.120.1 + k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 + sigs.k8s.io/controller-runtime v0.18.2 ) require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/emicklei/go-restful/v3 v3.11.0 // indirect - github.com/evanphx/json-patch v5.6.0+incompatible // indirect - github.com/evanphx/json-patch/v5 v5.7.0 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.12.0 // indirect + github.com/evanphx/json-patch v5.9.0+incompatible // indirect + github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.6 // indirect + github.com/go-ldap/ldap/v3 v3.4.8 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.20.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/gofuzz v1.2.0 // indirect - github.com/google/uuid v1.4.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/imdario/mergo v0.3.16 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/prometheus/client_golang v1.17.0 // indirect - github.com/prometheus/client_model v0.5.0 // indirect - github.com/prometheus/common v0.45.0 // indirect - github.com/prometheus/procfs v0.12.0 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.18.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.46.0 // indirect + github.com/prometheus/procfs v0.15.0 // indirect + github.com/spf13/cobra v1.8.0 // indirect github.com/spf13/pflag v1.0.5 // 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 - golang.org/x/net v0.19.0 // indirect - golang.org/x/oauth2 v0.15.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/term v0.15.0 // indirect - golang.org/x/text v0.14.0 // indirect + go.uber.org/zap v1.27.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect - google.golang.org/appengine v1.6.8 // indirect - google.golang.org/protobuf v1.31.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.28.4 // indirect - k8s.io/component-base v0.28.4 // indirect - k8s.io/kube-openapi v0.0.0-20231206194836-bf4651e18aa8 // indirect + k8s.io/apiextensions-apiserver v0.30.1 // indirect + k8s.io/component-base v0.30.1 // indirect + k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f // indirect + sigs.k8s.io/gateway-api v1.1.0 // indirect sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect sigs.k8s.io/yaml v1.4.0 // indirect diff --git a/go.sum b/go.sum index e10f250..40a2b17 100644 --- a/go.sum +++ b/go.sum @@ -1,72 +1,99 @@ -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/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8= +github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= +github.com/Keyfactor/ejbca-go-client-sdk v1.0.2 h1:pPnXCFfIFAwCjJrg1BtYlzoF8oHQ52sPOMs/uZ9uvZA= +github.com/Keyfactor/ejbca-go-client-sdk v1.0.2/go.mod h1:4Sv/KGVgRV4VXKko1ajfTaJwqJ5Aiw0VrDI9S7IcQ1g= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= +github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cert-manager/cert-manager v1.15.2 h1:Mjbvc+FjYeg2928xy7bcS+c+ARxyqBcXM9QypOg1/Uo= +github.com/cert-manager/cert-manager v1.15.2/go.mod h1:stBge/DTvrhfQMB/93+Y62s+gQgZBsfL1o0C/4AL/mI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= -github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/evanphx/json-patch v5.6.0+incompatible h1:jBYDEEiFBPxA0v50tFdvOzQQTCvpL6mnFh5mB2/l16U= -github.com/evanphx/json-patch v5.6.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= -github.com/evanphx/json-patch/v5 v5.7.0 h1:nJqP7uwL84RJInrohHfW0Fx3awjbm8qZeFv0nW9SYGc= -github.com/evanphx/json-patch/v5 v5.7.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.0 h1:y2DdzBAURM29NFF94q6RaY4vjIH1rtwDapwQtU84iWk= +github.com/emicklei/go-restful/v3 v3.12.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= +github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= -github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= -github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-asn1-ber/asn1-ber v1.5.6 h1:CYsqysemXfEaQbyrLJmdsCRuufHoLa3P/gGWGl5TDrM= +github.com/go-asn1-ber/asn1-ber v1.5.6/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= +github.com/go-ldap/ldap/v3 v3.4.8 h1:loKJyspcRezt2Q3ZRMq2p/0v8iOurlmeXDPw6fikSvQ= +github.com/go-ldap/ldap/v3 v3.4.8/go.mod h1:qS3Sjlu76eHfHGpUdWkAXQTw4beih+cHsco2jXlIXrk= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= -github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= -github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= +github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= -github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= -github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg= +github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 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/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= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -74,65 +101,87 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU= -github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= +github.com/onsi/ginkgo/v2 v2.17.2 h1:7eMhcy3GimbsA3hEnVKdw/PQM9XN9krpKVXsZdph0/g= +github.com/onsi/ginkgo/v2 v2.17.2/go.mod h1:nP2DPOQoNsQmsVyv5rDA8JkXQoCs6goXIvr/PRJ1eCc= +github.com/onsi/gomega v1.33.1 h1:dsYjIxxSR755MDmKVsaFQTE22ChNBcuuTWgkUDSubOk= +github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= -github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= -github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= -github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= -github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= -github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= -github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.46.0 h1:doXzt5ybi1HBKpsZOL0sSkaNHJJqkyfEWZGGqqScV0Y= +github.com/prometheus/common v0.46.0/go.mod h1:Tp0qkxpb9Jsg54QMe+EAmqXkSV7Evdy1BTn+g2pa/hQ= +github.com/prometheus/procfs v0.15.0 h1:A82kmvXJq2jTu5YUhSGNlYoxh85zLnKgPz4bMZgI5Ek= +github.com/prometheus/procfs v0.15.0/go.mod h1:Y0RJ/Y5g5wJpkTisOtqwDSo4HwhGmLB4VQSw2sQJLHk= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352 h1:CCriYyAfq1Br1aIYettdHZTy8mBTIPo7We18TuO/bak= go.mozilla.org/pkcs7 v0.0.0-20210826202110-33d05740a352/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= -go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= -go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= -golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= +golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= -golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= -golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= +golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -140,18 +189,28 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= -golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= -golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= +golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -159,48 +218,49 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= -google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.28.4 h1:8ZBrLjwosLl/NYgv1P7EQLqoO8MGQApnbgH8tu3BMzY= -k8s.io/api v0.28.4/go.mod h1:axWTGrY88s/5YE+JSt4uUi6NMM+gur1en2REMR7IRj0= -k8s.io/apiextensions-apiserver v0.28.4 h1:AZpKY/7wQ8n+ZYDtNHbAJBb+N4AXXJvyZx6ww6yAJvU= -k8s.io/apiextensions-apiserver v0.28.4/go.mod h1:pgQIZ1U8eJSMQcENew/0ShUTlePcSGFq6dxSxf2mwPM= -k8s.io/apimachinery v0.28.4 h1:zOSJe1mc+GxuMnFzD4Z/U1wst50X28ZNsn5bhgIIao8= -k8s.io/apimachinery v0.28.4/go.mod h1:wI37ncBvfAoswfq626yPTe6Bz1c22L7uaJ8dho83mgg= -k8s.io/client-go v0.28.4 h1:Np5ocjlZcTrkyRJ3+T3PkXDpe4UpatQxj85+xjaD2wY= -k8s.io/client-go v0.28.4/go.mod h1:0VDZFpgoZfelyP5Wqu0/r/TRYcLYuJ2U1KEeoaPa1N4= -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-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= -k8s.io/utils v0.0.0-20231127182322-b307cd553661/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/controller-runtime v0.16.3 h1:2TuvuokmfXvDUamSx1SuAOO3eTyye+47mJCigwG62c4= -sigs.k8s.io/controller-runtime v0.16.3/go.mod h1:j7bialYoSn142nv9sCOJmQgDXQXxnroFU4VnX/brVJ0= +k8s.io/api v0.30.1 h1:kCm/6mADMdbAxmIh0LBjS54nQBE+U4KmbCfIkF5CpJY= +k8s.io/api v0.30.1/go.mod h1:ddbN2C0+0DIiPntan/bye3SW3PdwLa11/0yqwvuRrJM= +k8s.io/apiextensions-apiserver v0.30.1 h1:4fAJZ9985BmpJG6PkoxVRpXv9vmPUOVzl614xarePws= +k8s.io/apiextensions-apiserver v0.30.1/go.mod h1:R4GuSrlhgq43oRY9sF2IToFh7PVlF1JjfWdoG3pixk4= +k8s.io/apimachinery v0.30.1 h1:ZQStsEfo4n65yAdlGTfP/uSHMQSoYzU/oeEbkmF7P2U= +k8s.io/apimachinery v0.30.1/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q= +k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc= +k8s.io/component-base v0.30.1 h1:bvAtlPh1UrdaZL20D9+sWxsJljMi0QZ3Lmw+kmZAaxQ= +k8s.io/component-base v0.30.1/go.mod h1:e/X9kDiOebwlI41AvBHuWdqFriSRrX50CdwA9TFaHLI= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f h1:0LQagt0gDpKqvIkAMPaRGcXawNMouPECM1+F9BVxEaM= +k8s.io/kube-openapi v0.0.0-20240430033511-f0e62f92d13f/go.mod h1:S9tOR0FxgyusSNR+MboCuiDpVWkAifZvaYI1Q2ubgro= +k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0 h1:jgGTlFYnhF1PM1Ax/lAlxUPE+KfCIXHaathvJg1C3ak= +k8s.io/utils v0.0.0-20240502163921-fe8a2dddb1d0/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/controller-runtime v0.18.2 h1:RqVW6Kpeaji67CY5nPEfRz6ZfFMk0lWQlNrLqlNpx+Q= +sigs.k8s.io/controller-runtime v0.18.2/go.mod h1:tuAt1+wbVsXIT8lPtk5RURxqAnq7xkpv2Mhttslg7Hw= +sigs.k8s.io/gateway-api v1.1.0 h1:DsLDXCi6jR+Xz8/xd0Z1PYl2Pn0TyaFMOPPZIj4inDM= +sigs.k8s.io/gateway-api v1.1.0/go.mod h1:ZH4lHrL2sDi0FHZ9jjneb8kKnGzFWyrTya35sWUTrRs= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= diff --git a/integration-manifest.json b/integration-manifest.json index 2f51cb5..0e34281 100644 --- a/integration-manifest.json +++ b/integration-manifest.json @@ -1,11 +1,12 @@ { - "$schema": "https://keyfactor.github.io/integration-manifest-schema.json", - "integration_type": "api-client", - "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": "" + "$schema": "https://keyfactor.github.io/v2/integration-manifest-schema.json", + "integration_type": "ejbca", + "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": "", + "update_catalog": true } diff --git a/internal/controllers/certificatesigningrequest_controller.go b/internal/controllers/certificatesigningrequest_controller.go index 7337f0b..0bba8ab 100644 --- a/internal/controllers/certificatesigningrequest_controller.go +++ b/internal/controllers/certificatesigningrequest_controller.go @@ -18,9 +18,12 @@ package controllers import ( "context" + "errors" "fmt" - "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/signer" - "github.com/Keyfactor/ejbca-k8s-csr-signer/pkg/util" + "strconv" + + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/ejbca" + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/util" v1 "k8s.io/api/authorization/v1" certificates "k8s.io/api/certificates/v1" corev1 "k8s.io/api/core/v1" @@ -32,11 +35,16 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" ) +var ( + errGetAuthSecret = errors.New("failed to get Secret containing credentials") + errSignerBuilder = errors.New("failed to build the signer") +) + type CertificateSigningRequestReconciler struct { client.Client ConfigClient util.ConfigClient Scheme *runtime.Scheme - SignerBuilder signer.Builder + SignerBuilder ejbca.SignerBuilder ClusterResourceNamespace string Clock clock.Clock CheckApprovedCondition, CheckServiceAccountScope bool @@ -48,13 +56,11 @@ type CertificateSigningRequestReconciler struct { func (c *CertificateSigningRequestReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, err error) { reconcileLog := ctrl.LoggerFrom(ctx) - c.SignerBuilder.Reset() - // Get the CertificateSigningRequest var certificateSigningRequest certificates.CertificateSigningRequest if err = c.Get(ctx, req.NamespacedName, &certificateSigningRequest); err != nil { if err = client.IgnoreNotFound(err); err != nil { - return ctrl.Result{}, fmt.Errorf("unexpected get error: %v", err) + return ctrl.Result{}, fmt.Errorf("unexpected get error: %w", err) } reconcileLog.Info("Not found. Ignoring.") return ctrl.Result{}, nil @@ -101,15 +107,54 @@ func (c *CertificateSigningRequestReconciler) Reconcile(ctx context.Context, req c.ConfigClient.SetContext(ctx) // Get the credentials secret - var creds corev1.Secret - if err = c.ConfigClient.GetSecret(c.CredsSecret, &creds); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to get Secret containing Signer credentials, secret name: %s, reason: %v", c.CredsSecret.Name, err) + var authSecret corev1.Secret + if err = c.ConfigClient.GetSecret(c.CredsSecret, &authSecret); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get Secret containing Signer credentials, secret name: %s, reason: %w", c.CredsSecret.Name, err) } // Get the signer configuration var config corev1.ConfigMap if err = c.ConfigClient.GetConfigMap(c.ConfigMap, &config); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to get ConfigMap containing Signer configuration, configmap name: %s, reason: %v", c.ConfigMap.Name, err) + return ctrl.Result{}, fmt.Errorf("failed to get ConfigMap containing Signer configuration, configmap name: %s, reason: %w", c.ConfigMap.Name, err) + } + + host, ok := config.Data["ejbcaHostname"] + if !ok || host == "" { + reconcileLog.Info("ejbcaHostname was not found in config map data - optimistically continuing") + } + + defaultEndEntityName, ok := config.Data["defaultEndEntityName"] + if !ok || defaultEndEntityName == "" { + reconcileLog.Info("defaultEndEntityName was not found in config map data - optimistically continuing") + } + + defaultCertificateProfileName, ok := config.Data["defaultCertificateProfileName"] + if !ok || defaultCertificateProfileName == "" { + reconcileLog.Info("defaultCertificateProfileName was not found in config map data - optimistically continuing") + } + + defaultEndEntityProfileName, ok := config.Data["defaultEndEntityProfileName"] + if !ok || defaultEndEntityProfileName == "" { + reconcileLog.Info("defaultEndEntityProfileName was not found in config map data - optimistically continuing") + } + + defaultCertificateAuthorityName, ok := config.Data["defaultCertificateAuthorityName"] + if !ok || defaultCertificateAuthorityName == "" { + reconcileLog.Info("defaultCertificateAuthorityName was not found in config map data - optimistically continuing") + } + + defaultESTAlias, ok := config.Data["defaultESTAlias"] + if !ok || defaultESTAlias == "" { + reconcileLog.Info("defaultESTAlias was not found in config map data - optimistically continuing") + } + + var chainDepth int + if chainDepthString, ok := config.Data["chainDepth"]; ok && chainDepthString != "" { + var err error + chainDepth, err = strconv.Atoi(chainDepthString) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to parse chainDepth from EJBCA config: %s is not a number: %w", chainDepthString, err) + } } // Get the CA certificate @@ -118,25 +163,95 @@ func (c *CertificateSigningRequestReconciler) Reconcile(ctx context.Context, req if c.CaCertConfigmap.Name != "" { err = c.ConfigClient.GetConfigMap(c.CaCertConfigmap, &root) if err != nil { - return ctrl.Result{}, fmt.Errorf("caSecretName was provided, but failed to get ConfigMap containing CA certificate, configmap name: %q, reason: %v", c.CaCertConfigmap, err) + return ctrl.Result{}, fmt.Errorf("caSecretName was provided, but failed to get ConfigMap containing CA certificate, configmap name: %q, reason: %w", c.CaCertConfigmap, err) } } - // Apply the configuration to the signer builder - c.SignerBuilder. - WithContext(ctx). - WithCredsSecret(creds). - WithConfigMap(config). - WithCACertConfigMap(root) + var authOpt ejbca.Option + switch { + case authSecret.Type == corev1.SecretTypeTLS: + cert, ok := authSecret.Data[corev1.TLSCertKey] + if !ok { + return ctrl.Result{}, fmt.Errorf("%w: %v", errGetAuthSecret, "found TLS secret with no certificate") + } + key, ok := authSecret.Data[corev1.TLSPrivateKeyKey] + if !ok { + return ctrl.Result{}, fmt.Errorf("%w: %v", errGetAuthSecret, "found TLS secret with no private key") + } + authOpt = ejbca.WithClientCert(&ejbca.CertAuth{ + ClientCert: cert, + ClientKey: key, + }) + case authSecret.Type == corev1.SecretTypeBasicAuth: + username, ok := authSecret.Data[corev1.BasicAuthUsernameKey] + if !ok { + return ctrl.Result{}, fmt.Errorf("%w, %v", errGetAuthSecret, "found basic auth secret with no username") + } + password, ok := authSecret.Data[corev1.BasicAuthPasswordKey] + if !ok { + return ctrl.Result{}, fmt.Errorf("%w, %v", errGetAuthSecret, "found basic auth secret with no password") + } + authOpt = ejbca.WithBasicAuth(&ejbca.BasicAuth{ + Username: string(username), + Password: string(password), + }) + case authSecret.Type == corev1.SecretTypeOpaque: + // We expect auth credentials for a client credential OAuth2.0 flow if the secret type is opaque + tokenURL, ok := authSecret.Data["tokenUrl"] + if !ok { + return ctrl.Result{}, fmt.Errorf("%w: %v", errGetAuthSecret, "found secret with no tokenUrl") + } + clientID, ok := authSecret.Data["clientId"] + if !ok { + return ctrl.Result{}, fmt.Errorf("%w: %v", errGetAuthSecret, "found secret with no clientId") + } + clientSecret, ok := authSecret.Data["clientSecret"] + if !ok { + return ctrl.Result{}, fmt.Errorf("%w: %v", errGetAuthSecret, "found secret with no clientSecret") + } + oauth := &ejbca.OAuth{ + TokenURL: string(tokenURL), + ClientID: string(clientID), + ClientSecret: string(clientSecret), + } + scopes, ok := authSecret.Data["scopes"] + if ok { + oauth.Scopes = string(scopes) + } + audience, ok := authSecret.Data["audience"] + if ok { + oauth.Audience = string(audience) + } + authOpt = ejbca.WithOAuth(oauth) + default: + return ctrl.Result{}, fmt.Errorf("%w: %v", errGetAuthSecret, "found secret with unsupported type") + } + + var caCertBytes []byte + // There is no requirement that the CA certificate is stored under a specific + // key in the secret, so we can just iterate over the map and effectively select + // the last value in the map + for _, content := range root.Data { + caCertBytes = []byte(content) + } - // Validate that there were no issues with the configuration - err = c.SignerBuilder.PreFlight() + signer, err := c.SignerBuilder(ctx, + ejbca.WithHostname(host), + ejbca.WithCACerts(caCertBytes), + authOpt, + ejbca.WithEndEntityProfileName(defaultEndEntityProfileName), + ejbca.WithCertificateProfileName(defaultCertificateProfileName), + ejbca.WithCertificateAuthority(defaultCertificateAuthorityName), + ejbca.WithEndEntityName(defaultEndEntityName), + ejbca.WithAnnotations(certificateSigningRequest.GetAnnotations()), + ejbca.WithESTAlias(defaultESTAlias), + ejbca.WithChainDepth(chainDepth), + ) if err != nil { - return ctrl.Result{}, err + return ctrl.Result{}, fmt.Errorf("%w: %w", errSignerBuilder, err) } - // Sign the certificate - leafAndChain, err := c.SignerBuilder.Build().Sign(certificateSigningRequest) + leafAndChain, err := signer.Sign(ctx, certificateSigningRequest.Spec.Request) if err != nil { return ctrl.Result{}, err } diff --git a/internal/controllers/certificatesigningrequest_controller_test.go b/internal/controllers/certificatesigningrequest_controller_test.go index d3ecb40..78a3489 100644 --- a/internal/controllers/certificatesigningrequest_controller_test.go +++ b/internal/controllers/certificatesigningrequest_controller_test.go @@ -19,7 +19,9 @@ package controllers import ( "context" "fmt" - "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/signer" + "testing" + + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/ejbca" logrtesting "github.com/go-logr/logr/testr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,7 +36,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "testing" ) var ( @@ -103,7 +104,7 @@ func TestCertificateSigningRequestReconciler_Reconcile(t *testing.T) { expectedError error expectedCertificate []byte credsSecret, configMap, caCertConfigmap types.NamespacedName - signerBuilder signer.Builder + signer ejbca.Signer checkScope bool } @@ -113,15 +114,15 @@ func TestCertificateSigningRequestReconciler_Reconcile(t *testing.T) { tests := map[string]testCase{ "not-found": { - name: namespacedCsrName, - signerBuilder: &FakeSignerBuilder{}, + name: namespacedCsrName, + signer: &FakeSigner{}, }, "not-approved": { name: namespacedCsrName, objects: []client.Object{ CreateCertificateSigningRequest(namespacedCsrName, certificates.CertificateDenied, nil, nil), }, - signerBuilder: &FakeSignerBuilder{}, + signer: &FakeSigner{}, }, "already-signed": { name: namespacedCsrName, @@ -129,7 +130,7 @@ func TestCertificateSigningRequestReconciler_Reconcile(t *testing.T) { CreateCertificateSigningRequest(namespacedCsrName, certificates.CertificateApproved, nil, fakeSuccessCertificate), }, expectedCertificate: fakeSuccessCertificate, - signerBuilder: &FakeSignerBuilder{}, + signer: &FakeSigner{}, }, "no-creds": { name: namespacedCsrName, @@ -137,7 +138,7 @@ func TestCertificateSigningRequestReconciler_Reconcile(t *testing.T) { CreateCertificateSigningRequest(namespacedCsrName, certificates.CertificateApproved, nil, nil), }, expectedError: fmt.Errorf("failed to get Secret containing Signer credentials, secret name: , reason: secrets \"\" not found"), - signerBuilder: &FakeSignerBuilder{}, + signer: &FakeSigner{}, }, "no-configmap": { name: namespacedCsrName, @@ -147,7 +148,7 @@ func TestCertificateSigningRequestReconciler_Reconcile(t *testing.T) { }, credsSecret: namespacedCredsName, expectedError: fmt.Errorf("failed to get ConfigMap containing Signer configuration, configmap name: , reason: configmaps \"\" not found"), - signerBuilder: &FakeSignerBuilder{}, + signer: &FakeSigner{}, }, "no-ca-cert-configmap": { name: namespacedCsrName, @@ -160,7 +161,7 @@ func TestCertificateSigningRequestReconciler_Reconcile(t *testing.T) { configMap: namespacedCredsName, caCertConfigmap: namespacedCaCertConfigmapName, expectedError: fmt.Errorf("caSecretName was provided, but failed to get ConfigMap containing CA certificate, configmap name: %q, reason: configmaps %q not found", namespacedCaCertConfigmapName, namespacedCaCertConfigmapName.Name), - signerBuilder: &FakeSignerBuilder{}, + signer: &FakeSigner{}, }, "sign-error": { name: namespacedCsrName, @@ -171,7 +172,7 @@ func TestCertificateSigningRequestReconciler_Reconcile(t *testing.T) { }, credsSecret: namespacedCredsName, configMap: namespacedCredsName, - signerBuilder: &FakeSignerBuilder{ + signer: &FakeSigner{ errSign: fmt.Errorf("sign error"), }, expectedError: fmt.Errorf("sign error"), @@ -185,7 +186,7 @@ func TestCertificateSigningRequestReconciler_Reconcile(t *testing.T) { }, credsSecret: namespacedCredsName, configMap: namespacedCredsName, - signerBuilder: &FakeSignerBuilder{}, + signer: &FakeSigner{}, expectedCertificate: fakeSuccessCertificate, }, "denied": { @@ -195,17 +196,17 @@ func TestCertificateSigningRequestReconciler_Reconcile(t *testing.T) { CreateFakeCreds(namespacedCredsName), CreateFakeConfig(namespacedCredsName), }, - credsSecret: namespacedCredsName, - configMap: namespacedCredsName, - signerBuilder: &FakeSignerBuilder{}, + credsSecret: namespacedCredsName, + configMap: namespacedCredsName, + signer: &FakeSigner{}, }, "check-scope": { name: namespacedCsrName, objects: []client.Object{ CreateCertificateSigningRequest(namespacedCsrName, certificates.CertificateApproved, fakeCsr, nil), }, - signerBuilder: &FakeSignerBuilder{}, - checkScope: true, + signer: &FakeSigner{}, + checkScope: true, // The fake client does not have a fake SelfSubjectAccessReview API, so we expect an error expectedError: fmt.Errorf(" \"\" is invalid: metadata.name: Required value: name is required"), }, @@ -226,7 +227,9 @@ func TestCertificateSigningRequestReconciler_Reconcile(t *testing.T) { ConfigClient: NewFakeConfigClient(fakeClient), Scheme: scheme, ClusterResourceNamespace: tc.clusterResourceNamespace, - SignerBuilder: tc.signerBuilder, + SignerBuilder: func(_ context.Context, _ ...ejbca.Option) (ejbca.Signer, error) { + return tc.signer, nil + }, CheckApprovedCondition: true, Clock: fixedClock, CredsSecret: tc.credsSecret, diff --git a/internal/controllers/fake_configclient_test.go b/internal/controllers/fake_configclient_test.go index 413722c..72852fc 100644 --- a/internal/controllers/fake_configclient_test.go +++ b/internal/controllers/fake_configclient_test.go @@ -18,7 +18,8 @@ package controllers import ( "context" - "github.com/Keyfactor/ejbca-k8s-csr-signer/pkg/util" + + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/util" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" diff --git a/internal/controllers/fake_signer_test.go b/internal/controllers/fake_signer_test.go index 1cc68b0..49f4f73 100644 --- a/internal/controllers/fake_signer_test.go +++ b/internal/controllers/fake_signer_test.go @@ -18,47 +18,17 @@ package controllers import ( "context" - "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/signer" - certificates "k8s.io/api/certificates/v1" - corev1 "k8s.io/api/core/v1" + + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/ejbca" ) -var _ signer.Builder = &FakeSignerBuilder{} -var _ signer.Signer = &FakeSignerBuilder{} +var _ ejbca.Signer = &FakeSigner{} -type FakeSignerBuilder struct { +type FakeSigner struct { errSign error } -func (f *FakeSignerBuilder) Reset() signer.Builder { - return f -} - -func (f *FakeSignerBuilder) WithContext(ctx context.Context) signer.Builder { - return f -} - -func (f *FakeSignerBuilder) WithCredsSecret(secret corev1.Secret) signer.Builder { - return f -} - -func (f *FakeSignerBuilder) WithConfigMap(configMap corev1.ConfigMap) signer.Builder { - return f -} - -func (f *FakeSignerBuilder) WithCACertConfigMap(configMap corev1.ConfigMap) signer.Builder { - return f -} - -func (f *FakeSignerBuilder) PreFlight() error { - return nil -} - -func (f *FakeSignerBuilder) Build() signer.Signer { - return f -} - -func (f *FakeSignerBuilder) Sign(csr certificates.CertificateSigningRequest) ([]byte, error) { +func (f FakeSigner) Sign(context.Context, []byte) ([]byte, error) { return fakeSuccessCertificate, f.errSign } diff --git a/internal/ejbca/client.go b/internal/ejbca/client.go new file mode 100644 index 0000000..897cde3 --- /dev/null +++ b/internal/ejbca/client.go @@ -0,0 +1,187 @@ +/* +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 ejbca + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net/http" + "strings" + + "github.com/Keyfactor/ejbca-go-client-sdk/api/ejbca" + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/est" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +type CertificateClient interface { + EnrollPkcs10Certificate(ctx context.Context) ejbca.ApiEnrollPkcs10CertificateRequest + Status2(ctx context.Context) ejbca.ApiStatus2Request +} + +func (s *signer) getCAChain(ctx context.Context) ([]*x509.Certificate, error) { + logger := log.FromContext(ctx).WithName("signer.getCAChain") + var caChain []*x509.Certificate + if len(s.config.caCertsBytes) > 0 { + logger.Info("CA chain present - Parsing CA chain from configuration") + + blocks := decodePEMBytes(s.config.caCertsBytes) + if len(blocks) == 0 { + return nil, fmt.Errorf("didn't find pem certificate in ca cert configmap") + } + + for _, block := range blocks { + // Parse the PEM block into an x509 certificate + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, fmt.Errorf("failed to parse CA certificate: %w", err) + } + + caChain = append(caChain, cert) + } + + logger.Info("Parsed CA chain", "length", len(caChain)) + } + return caChain, nil +} + +func (s *signer) newAuthenticator(ctx context.Context) (ejbca.Authenticator, error) { + var err error + logger := log.FromContext(ctx).WithName("signer.newEjbcaClient") + + caChain, err := s.getCAChain(ctx) + if err != nil { + return nil, fmt.Errorf("couldn't get CA chain for EJBCA authenticator: %w", err) + } + + var authenticator ejbca.Authenticator + switch { + case s.config.oauth != nil: + logger.Info("Creating OAuth authenticator") + scopes := strings.Split(s.config.oauth.Scopes, " ") + + authenticator, err = ejbca.NewOAuthAuthenticatorBuilder(). + WithCaCertificates(caChain). + WithTokenUrl(s.config.oauth.TokenURL). + WithClientId(s.config.oauth.ClientID). + WithClientSecret(s.config.oauth.ClientSecret). + WithAudience(s.config.oauth.Audience). + WithScopes(scopes). + Build() + if err != nil { + logger.Error(err, "Failed to build OAuth authenticator") + return nil, fmt.Errorf("failed to build OAuth authenticator: %w", err) + } + + logger.Info("Created OAuth authenticator") + case s.config.certAuth != nil: + logger.Info("Creating mTLS authenticator") + + var tlsCert tls.Certificate + tlsCert, err := tls.X509KeyPair(s.config.certAuth.ClientCert, s.config.certAuth.ClientKey) + if err != nil { + return nil, fmt.Errorf("failed to load client certificate: %w", err) + } + + authenticator, err = ejbca.NewMTLSAuthenticatorBuilder(). + WithCaCertificates(caChain). + WithClientCertificate(&tlsCert). + Build() + if err != nil { + logger.Error(err, "Failed to build mTLS authenticator") + return nil, fmt.Errorf("failed to build MTLS authenticator: %w", err) + } + + logger.Info("Created mTLS authenticator") + default: + err := errors.New("no authentication method specified") + logger.Error(err, "No authentication method specified") + return nil, err + } + + return authenticator, nil +} + +func (s *signer) newESTClient(ctx context.Context) (est.Client, error) { + caChain, err := s.getCAChain(ctx) + if err != nil { + return nil, fmt.Errorf("couldn't get CA chain for EJBCA authenticator: %w", err) + } + + client, err := est.NewBuilder(s.config.hostname). + WithContext(ctx). + WithClient(http.DefaultClient). + WithCaCertificates(caChain). + WithBasicAuth(s.config.basicAuth.Username, s.config.basicAuth.Password). + WithDefaultESTAlias(s.config.estAliasName). + Build() + if err != nil { + return nil, fmt.Errorf("Error creating EST client: %w", err) + } + + return client, nil +} + +// newEjbcaClient generates a new EJBCA client based on the provided configuration. +func (s *signer) newEjbcaClient(ctx context.Context) (CertificateClient, error) { + if s.config == nil || s.hooks.newAuthenticator == nil { + return nil, errors.New("newEjbcaClient was called incorrectly - this is a bug - please report it to the EJBCA authors") + } + + logger := log.FromContext(ctx).WithName("signer.newEjbcaClient") + + authenticator, err := s.hooks.newAuthenticator(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create new EJBCA API authenticator: %w", err) + } + if authenticator == nil { + return nil, errors.New("authenticator is nil - this is a bug - please report it to the EJBCA authors") + } + + configuration := ejbca.NewConfiguration() + configuration.Host = s.config.hostname + + configuration.SetAuthenticator(authenticator) + + ejbcaClient, err := ejbca.NewAPIClient(configuration) + if err != nil { + return nil, err + } + + logger.Info("Created EJBCA REST API client") + return ejbcaClient.V1CertificateApi, nil +} + +// parseEjbcaError parses an error returned by the EJBCA API and returns a gRPC status error. +func (s *signer) parseEjbcaError(ctx context.Context, detail string, err error) error { + if err == nil { + return nil + } + logger := log.FromContext(ctx).WithName("signer.parseEjbcaError") + errString := fmt.Sprintf("%s - %s", detail, err.Error()) + + ejbcaError := &ejbca.GenericOpenAPIError{} + if errors.As(err, &ejbcaError) { + errString += fmt.Sprintf(" - EJBCA API returned error %s", ejbcaError.Body()) + } + + logger.Error(err, "EJBCA returned an error") + + return errors.New(errString) +} diff --git a/internal/ejbca/ejbca.go b/internal/ejbca/ejbca.go new file mode 100644 index 0000000..566262f --- /dev/null +++ b/internal/ejbca/ejbca.go @@ -0,0 +1,644 @@ +/* +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 ejbca + +import ( + "context" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "math/big" + "strconv" + + "github.com/Keyfactor/ejbca-go-client-sdk/api/ejbca" + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/est" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +const ( + ejbcaAnnotationPrefix = "ejbca-k8s-csr-signer.keyfactor.com/" +) + +type SignerBuilder func(ctx context.Context, opts ...Option) (Signer, error) +type newEjbcaAuthenticatorFunc func(context.Context) (ejbca.Authenticator, error) + +type HealthChecker interface { + Check() error +} + +type Signer interface { + Sign(context.Context, []byte) ([]byte, error) +} + +type internalSigner interface { + Signer + getConfig() *Config +} + +type signer struct { + restClient CertificateClient + estClient est.Client + config *Config + + hooks struct { + newAuthenticator newEjbcaAuthenticatorFunc + } +} + +type CertAuth struct { + ClientCert []byte + ClientKey []byte +} + +type BasicAuth struct { + Username string + Password string +} + +type OAuth struct { + TokenURL string + ClientID string + ClientSecret string + Scopes string + Audience string +} + +type Config struct { + hostname string + caCertsBytes []byte + certAuth *CertAuth + oauth *OAuth + basicAuth *BasicAuth + certificateProfileName string + endEntityProfileName string + certificateAuthorityName string + endEntityName string + estAliasName string + chainDepth int + annotations map[string]string +} + +func (c *Config) validate(ctx context.Context) error { + logger := log.FromContext(ctx).WithName("config.validate") + // Override defaults from annotations + if value, exists := c.annotations[ejbcaAnnotationPrefix+"certificateAuthorityName"]; exists { + logger.Info("Found annotation override for certificateAuthorityName", "was", c.certificateAuthorityName, "now", value) + c.certificateAuthorityName = value + } else if value = c.deprecatedAnnotationGetter(ctx, c.annotations, "certificateAuthorityName"); value != "" { + logger.Info("Found annotation override for certificateAuthorityName", "was", c.certificateProfileName, "now", value) + c.certificateAuthorityName = value + } + + if value, exists := c.annotations[ejbcaAnnotationPrefix+"certificateProfileName"]; exists { + logger.Info("Found annotation override for certificateProfileName", "was", c.certificateProfileName, "now", value) + c.certificateProfileName = value + } else if value = c.deprecatedAnnotationGetter(ctx, c.annotations, "certificateProfileName"); value != "" { + logger.Info("Found annotation override for certificateProfileName", "was", c.certificateProfileName, "now", value) + c.certificateProfileName = value + } + + if value, exists := c.annotations[ejbcaAnnotationPrefix+"endEntityName"]; exists { + logger.Info("Found annotation override for endEntityName", "was", c.endEntityName, "now", value) + c.endEntityName = value + } else if value = c.deprecatedAnnotationGetter(ctx, c.annotations, "endEntityName"); value != "" { + logger.Info("Found annotation override for endEntityName", "was", c.certificateProfileName, "now", value) + c.endEntityName = value + } + if value, exists := c.annotations[ejbcaAnnotationPrefix+"estAlias"]; exists { + logger.Info("Found annotation override for estAlias", "was", c.estAliasName, "now", value) + c.estAliasName = value + } else if value = c.deprecatedAnnotationGetter(ctx, c.annotations, "estAlias"); value != "" { + logger.Info("Found annotation override for endEntityName", "was", c.certificateProfileName, "now", value) + c.estAliasName = value + } + + if value, exists := c.annotations[ejbcaAnnotationPrefix+"endEntityProfileName"]; exists { + logger.Info("Found annotation override for endEntityProfileName", "was", c.endEntityProfileName, "now", value) + c.endEntityProfileName = value + } else if value = c.deprecatedAnnotationGetter(ctx, c.annotations, "endEntityProfileName"); value != "" { + logger.Info("Found annotation override for endEntityProfileName", "was", c.certificateProfileName, "now", value) + c.endEntityProfileName = value + } + + if value, exists := c.annotations[ejbcaAnnotationPrefix+"chainDepth"]; exists { + logger.Info("Found annotation override for chainDepth", "was", c.chainDepth, "now", value) + chainDepth, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("%s is not a number: %w", value, err) + } + c.chainDepth = chainDepth + } else if value = c.deprecatedAnnotationGetter(ctx, c.annotations, "chainDepth"); value != "" { + logger.Info("Found annotation override for chainDepth", "was", c.certificateProfileName, "now", value) + chainDepth, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("%s is not a number: %w", value, err) + } + c.chainDepth = chainDepth + } + + switch { + case c.hostname == "": + err := errors.New("hostname is required") + logger.Error(err, "hostname is required") + return err + case c.certificateProfileName == "": + err := errors.New("certificateProfileName is required") + logger.Error(err, "certificateProfileName is required") + return err + case c.endEntityProfileName == "": + err := errors.New("endEntityProfileName is required") + logger.Error(err, "endEntityProfileName is required") + return err + case c.certificateAuthorityName == "": + err := errors.New("certificateAuthorityName is required") + logger.Error(err, "certificateAuthorityName is required") + return err + } + + if c.certAuth == nil && c.oauth == nil && c.basicAuth == nil { + return errors.New("no auth was configured. please consult the docs to ensure that an auth method was configured") + } + + var method string + switch { + case c.certAuth != nil: + if len(c.certAuth.ClientCert) == 0 { + err := errors.New("client certificate is required") + logger.Error(err, "client certificate is required") + return err + } + if len(c.certAuth.ClientKey) == 0 { + err := errors.New("client key is required") + logger.Error(err, "client key is required") + return err + } + method = "mtls" + case c.basicAuth != nil: + if c.basicAuth.Username == "" { + err := errors.New("username is required") + logger.Error(err, "username is required") + return err + } + if c.basicAuth.Password == "" { + err := errors.New("password is required") + logger.Error(err, "password is required") + return err + } + if c.estAliasName == "" { + err := errors.New("estAliasName is required") + logger.Error(err, "estAliasName is required") + return err + } + method = "est" + default: + if c.oauth.TokenURL == "" { + err := errors.New("token URL is required") + logger.Error(err, "token URL is required") + return err + } + if c.oauth.ClientID == "" { + err := errors.New("client ID is required") + logger.Error(err, "client ID is required") + return err + } + if c.oauth.ClientSecret == "" { + err := errors.New("client secret is required") + logger.Error(err, "client secret is required") + return err + } + method = "oauth" + } + + logger.Info("Configuration validated", "authentication", method, "hostname", c.hostname, "certificateProfileName", c.certificateProfileName, "endEntityProfileName", c.endEntityProfileName, "certificateAuthorityName", c.certificateAuthorityName, "endEntityName", c.endEntityName, "chainDepth", c.chainDepth, "estAliasName", c.estAliasName) + return nil +} + +type Option func(*signer) + +func newInternalSigner(ctx context.Context, opts ...Option) (internalSigner, error) { + s := &signer{ + config: &Config{}, + } + if s.hooks.newAuthenticator == nil { + // Default newAuthenticator hook is the production one. Can be overridden by tests. + s.hooks.newAuthenticator = s.newAuthenticator + } + for _, opt := range opts { + opt(s) + } + if err := s.config.validate(ctx); err != nil { + return nil, err + } + if s.config.basicAuth != nil { + client, err := s.newESTClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to build EST certificate client: %w", err) + } + s.estClient = client + } else { + client, err := s.newEjbcaClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to create new EJBCA API client: %w", err) + } + s.restClient = client + } + return s, nil +} + +func NewSigner(ctx context.Context, opts ...Option) (Signer, error) { + return newInternalSigner(ctx, opts...) +} + +func WithHostname(hostname string) Option { + return func(s *signer) { + s.config.hostname = hostname + } +} + +func WithCACerts(caBytes []byte) Option { + return func(s *signer) { + s.config.caCertsBytes = caBytes + } +} + +func WithClientCert(certAuth *CertAuth) Option { + return func(s *signer) { + s.config.certAuth = certAuth + } +} + +func WithOAuth(oAuth *OAuth) Option { + return func(s *signer) { + s.config.oauth = oAuth + } +} + +func WithBasicAuth(basicAuth *BasicAuth) Option { + return func(s *signer) { + s.config.basicAuth = basicAuth + } +} + +func WithCertificateProfileName(cpn string) Option { + return func(s *signer) { + s.config.certificateProfileName = cpn + } +} + +func WithEndEntityProfileName(eepn string) Option { + return func(s *signer) { + s.config.endEntityProfileName = eepn + } +} + +func WithESTAlias(alias string) Option { + return func(s *signer) { + s.config.estAliasName = alias + } +} + +func WithEndEntityName(ee string) Option { + return func(s *signer) { + s.config.endEntityName = ee + } +} + +func WithChainDepth(depth int) Option { + return func(s *signer) { + s.config.chainDepth = depth + } +} + +func WithCertificateAuthority(ca string) Option { + return func(s *signer) { + s.config.certificateAuthorityName = ca + } +} + +func WithAnnotations(annotations map[string]string) Option { + return func(s *signer) { + s.config.annotations = annotations + } +} + +func withAuthenticator(newAuthenticator newEjbcaAuthenticatorFunc) Option { + return func(s *signer) { + s.hooks.newAuthenticator = newAuthenticator + } +} + +// Check checks the status of the EJBCA API +func (s *signer) Check() error { + _, r, err := s.restClient.Status2(context.Background()).Execute() + if err != nil { + return err + } + defer r.Body.Close() + return nil +} + +// Sign signs a CSR with EJBCA +func (s *signer) Sign(ctx context.Context, csrBytes []byte) ([]byte, error) { + if s.restClient != nil { + return s.restSign(ctx, csrBytes) + } + return s.estSign(ctx, csrBytes) +} + +func (s *signer) restSign(ctx context.Context, csrBytes []byte) ([]byte, error) { + logger := log.FromContext(ctx).WithName("signer.restSign") + + csr, err := parseCSR(csrBytes) + if err != nil { + return nil, err + } + + logger.Info("Parsed CSR from CertificateRequest", "commonName", csr.Subject.CommonName, "dnsNames", csr.DNSNames, "ipAddresses", csr.IPAddresses, "uriSans", csr.URIs) + + ejbcaEeName := s.getEndEntityName(ctx, csr) + if ejbcaEeName == "" { + return nil, errors.New("failed to determine the EJBCA end entity name") + } + + logger.Info(fmt.Sprintf("Using or Creating EJBCA End Entity called %q", ejbcaEeName)) + + password, err := generateRandomString(20) + if err != nil { + return nil, fmt.Errorf("error generating random password: %w", err) + } + + // Configure EJBCA PKCS#10 request + enroll := ejbca.NewEnrollCertificateRestRequest() + enroll.SetCertificateRequest(string(csrBytes)) + enroll.SetCertificateProfileName(s.config.certificateProfileName) + enroll.SetEndEntityProfileName(s.config.endEntityProfileName) + enroll.SetCertificateAuthorityName(s.config.certificateAuthorityName) + enroll.SetUsername(ejbcaEeName) + enroll.SetPassword(password) + enroll.SetIncludeChain(true) + + logger.Info("Enrolling certificate with EJBCA", "commonName", csr.Subject.CommonName, "dnsNames", csr.DNSNames, "ipAddresses", csr.IPAddresses, "uriSans", csr.URIs) + + enrollResponse, r, err := s.restClient.EnrollPkcs10Certificate(ctx). + EnrollCertificateRestRequest(*enroll). + Execute() + if err != nil { + return nil, s.parseEjbcaError(ctx, "failed to enroll CSR", err) + } + defer r.Body.Close() + + var certBytes []byte + var caBytes []byte + switch { + case enrollResponse.GetResponseFormat() == "PEM": + logger.Info("EJBCA returned certificate in PEM format - serializing") + + block, _ := pem.Decode([]byte(enrollResponse.GetCertificate())) + if block == nil { + return nil, errors.New("failed to parse certificate PEM") + } + certBytes = block.Bytes + + for _, ca := range enrollResponse.CertificateChain { + block, _ := pem.Decode([]byte(ca)) + if block == nil { + return nil, errors.New("failed to parse CA certificate PEM") + } + caBytes = append(caBytes, block.Bytes...) + } + case enrollResponse.GetResponseFormat() == "DER": + logger.Info("EJBCA returned certificate in DER format - serializing") + + bytes := []byte(enrollResponse.GetCertificate()) + bytes, err := base64.StdEncoding.DecodeString(string(bytes)) + if err != nil { + return nil, fmt.Errorf("failed to base64 decode DER certificate: %w", err) + } + certBytes = append(certBytes, bytes...) + + for _, ca := range enrollResponse.CertificateChain { + bytes := []byte(ca) + bytes, err := base64.StdEncoding.DecodeString(string(bytes)) + if err != nil { + return nil, fmt.Errorf("failed to base64 decode DER CA certificate: %w", err) + } + caBytes = append(caBytes, bytes...) + } + default: + return nil, errors.New("ejbca returned unsupported certificate format: " + enrollResponse.GetResponseFormat()) + } + + leaf, err := x509.ParseCertificate(certBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate issued by EJBCA: %w", err) + } + + caChain, err := x509.ParseCertificates(caBytes) + if err != nil { + return nil, fmt.Errorf("failed to parse CA chain returned by EJBCA: %w", err) + } + + if len(caChain) == 0 { + return nil, errors.New("EJBCA did not return a CA chain") + } + + var leafAndChain []*x509.Certificate + leafAndChain = append(leafAndChain, leaf) + leafAndChain = append(leafAndChain, caChain...) + + // Then, construct the PEM list according to chainDepth + + /* + chainDepth = 0 => whole chain + chainDepth = 1 => just the leaf + chainDepth = 2 => leaf + issuer + chainDepth = 3 => leaf + issuer + issuer + etc + */ + + // The two scenarios where we want the whole chain are when chainDepth is 0 or greater than the length of the whole chain + var pemChain []byte + if s.config.chainDepth == 0 || s.config.chainDepth > len(leafAndChain) { + s.config.chainDepth = len(leafAndChain) + } + for i := 0; i < s.config.chainDepth; i++ { + pemChain = append(pemChain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafAndChain[i].Raw})...) + } + + logger.Info(fmt.Sprintf("Successfully enrolled certificate with EJBCA and built leaf and chain to depth %d", s.config.chainDepth)) + + // Return the certificate and chain in PEM format + return pemChain, nil +} + +func (s *signer) estSign(ctx context.Context, csrBytes []byte) ([]byte, error) { + logger := log.FromContext(ctx).WithName("signer.estSign") + // Decode PEM encoded PKCS#10 CSR to DER + block, _ := pem.Decode(csrBytes) + + // Enroll CSR with simpleenroll + leaf, err := s.estClient.SimpleEnroll(s.config.estAliasName, base64.StdEncoding.EncodeToString(block.Bytes)) + if err != nil { + return nil, err + } + + // Grab the CA chain of trust from cacerts + chain, err := s.estClient.CaCerts(s.config.estAliasName) + if err != nil { + return nil, err + } + + /* + chainDepth = 0 => whole chain + chainDepth = 1 => just the leaf + chainDepth = 2 => leaf + issuer + chainDepth = 3 => leaf + issuer + issuer + etc + */ + + // Build a list of the leaf and the whole chain + var leafAndChain []*x509.Certificate + leafAndChain = append(leafAndChain, leaf[0]) + leafAndChain = append(leafAndChain, chain...) + + // The two scenarios where we want the whole chain are when chainDepth is 0 or greater than the length of the whole chain + // IE if chainDepth == len(leafAndChain), the whole chain will be appended anyway + var pemChain []byte + if s.config.chainDepth == 0 || s.config.chainDepth > len(leafAndChain) { + s.config.chainDepth = len(leafAndChain) + } + for i := 0; i < s.config.chainDepth; i++ { + pemChain = append(pemChain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafAndChain[i].Raw})...) + } + + logger.Info(fmt.Sprintf("Successfully enrolled certificate with EJBCA and built leaf and chain to depth %d", s.config.chainDepth)) + + return pemChain, nil +} + +func (s *signer) getConfig() *Config { + return s.config +} + +// getEndEntityName determines the end entity name to use for the EJBCA request +func (s *signer) getEndEntityName(ctx context.Context, csr *x509.CertificateRequest) string { + logger := log.FromContext(ctx).WithName("signer.getEndEntityName") + eeName := "" + // 1. If the endEntityName option is set, determine the end entity name based on the option + // 2. If the endEntityName option is not set, determine the end entity name based on the CSR + + // cn: Use the CommonName from the CertificateRequest's DN + if s.config.endEntityName == "cn" || s.config.endEntityName == "" { + if csr.Subject.CommonName != "" { + eeName = csr.Subject.CommonName + logger.Info(fmt.Sprintf("Using CommonName from the CertificateRequest's DN as the EJBCA end entity name: %q", eeName)) + return eeName + } + } + + // dns: Use the first DNSName from the CertificateRequest's DNSNames SANs + if s.config.endEntityName == "dns" || s.config.endEntityName == "" { + if len(csr.DNSNames) > 0 && csr.DNSNames[0] != "" { + eeName = csr.DNSNames[0] + logger.Info(fmt.Sprintf("Using the first DNSName from the CertificateRequest's DNSNames SANs as the EJBCA end entity name: %q", eeName)) + return eeName + } + } + + // uri: Use the first URI from the CertificateRequest's URI Sans + if s.config.endEntityName == "uri" || s.config.endEntityName == "" { + if len(csr.URIs) > 0 { + eeName = csr.URIs[0].String() + logger.Info(fmt.Sprintf("Using the first URI from the CertificateRequest's URI Sans as the EJBCA end entity name: %q", eeName)) + return eeName + } + } + + // ip: Use the first IPAddress from the CertificateRequest's IPAddresses SANs + if s.config.endEntityName == "ip" || s.config.endEntityName == "" { + if len(csr.IPAddresses) > 0 { + eeName = csr.IPAddresses[0].String() + logger.Info(fmt.Sprintf("Using the first IPAddress from the CertificateRequest's IPAddresses SANs as the EJBCA end entity name: %q", eeName)) + return eeName + } + } + + // End of defaults; if the endEntityName option is set to anything but cn, dns, or uri, use the option as the end entity name + if s.config.endEntityName != "" && s.config.endEntityName != "cn" && s.config.endEntityName != "dns" && s.config.endEntityName != "uri" && s.config.endEntityName != "certificateName" { + eeName = s.config.endEntityName + logger.Info(fmt.Sprintf("Using the endEntityName option as the EJBCA end entity name: %q", eeName)) + return eeName + } + + // If we get here, we were unable to determine the end entity name + logger.Error(fmt.Errorf("unsuccessfully determined end entity name"), fmt.Sprintf("the endEntityName option is set to %q, but no valid end entity name could be determined from the CertificateRequest", s.config.endEntityName)) + + return eeName +} + +// deprecatedAnnotationGetter is a helper function to get annotations that were +// specified without the ejbca-k8s-csr-signer.keyfactor.com/ prefix. +func (c *Config) deprecatedAnnotationGetter(ctx context.Context, annotations map[string]string, annotation string) string { + logger := log.FromContext(ctx).WithName("signer.deprecatedAnnotationGetter") + annotationValue, ok := annotations[annotation] + if ok { + logger.Info(fmt.Sprintf("Annotations specified without the %q prefix is deprecated and will be removed in the future. Using %q as %q", ejbcaAnnotationPrefix, annotationValue, annotation)) + return annotationValue + } + + return "" +} + +func parseCSR(pemBytes []byte) (*x509.CertificateRequest, error) { + // extract PEM from request object + block, _ := pem.Decode(pemBytes) + if block == nil || block.Type != "CERTIFICATE REQUEST" { + return nil, errors.New("PEM block type must be CERTIFICATE REQUEST") + } + return x509.ParseCertificateRequest(block.Bytes) +} + +// decodePEMBytes takes a byte array containing PEM encoded data and returns a slice of PEM blocks and a private key PEM block +func decodePEMBytes(buf []byte) []*pem.Block { + var certificates []*pem.Block + var block *pem.Block + for { + block, buf = pem.Decode(buf) + if block == nil { + break + } + certificates = append(certificates, block) + } + return certificates +} + +// generateRandomString generates a random string of the specified length +func generateRandomString(length int) (string, error) { + letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + b := make([]rune, length) + for i := range b { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + if err != nil { + return "", err + } + b[i] = letters[num.Int64()] + } + return string(b), nil +} diff --git a/internal/ejbca/ejbca_test.go b/internal/ejbca/ejbca_test.go new file mode 100644 index 0000000..92b60e7 --- /dev/null +++ b/internal/ejbca/ejbca_test.go @@ -0,0 +1,962 @@ +/* +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 ejbca + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/Keyfactor/ejbca-go-client-sdk/api/ejbca" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeEjbcaAuthenticator struct { + client *http.Client +} + +// GetHTTPClient implements ejbcaclient.Authenticator +func (f *fakeEjbcaAuthenticator) GetHTTPClient() (*http.Client, error) { + return f.client, nil +} + +type fakeClientConfig struct { + testServer *httptest.Server +} + +func (f *fakeClientConfig) newFakeAuthenticator(context.Context) (ejbca.Authenticator, error) { + return &fakeEjbcaAuthenticator{ + client: f.testServer.Client(), + }, nil +} + +func TestNewSigner(t *testing.T) { + caCert, rootKey := issueTestCertificate(t, "Root-CA", nil, nil) + caCertPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}) + + serverCert, _ := issueTestCertificate(t, "Server", caCert, rootKey) + serverCertPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverCert.Raw}) + + authCert, authKey := issueTestCertificate(t, "Auth", caCert, rootKey) + keyByte, err := x509.MarshalECPrivateKey(authKey) + require.NoError(t, err) + authCertPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: authCert.Raw}) + keyPem := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyByte}) + + for i, tt := range []struct { + name string + opts []Option + + expectError bool + expectedErrorMessagePrefix string + }{ + { + name: "No opts provided", + + expectError: true, + expectedErrorMessagePrefix: "", + }, + { + name: "No hostname", + opts: []Option{ + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "hostname is required", + }, + { + name: "No CA name", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "certificateAuthorityName is required", + }, + { + name: "No Certificate Profile name", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }), + WithCertificateAuthority("Root-CA"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "certificateProfileName is required", + }, + { + name: "No End Entity Profile name", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "endEntityProfileName is required", + }, + { + name: "No Client Certificate", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientKey: keyPem, + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "client certificate is required", + }, + { + name: "No Client Key", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientCert: authCertPem, + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "client key is required", + }, + { + name: "Invalid CA certificate", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts([]byte("not-a-ca-cert")), + WithClientCert(&CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "failed to create new EJBCA API client: failed to create new EJBCA API authenticator: couldn't get CA chain for EJBCA authenticator: didn't find pem certificate in ca cert configmap", + }, + { + name: "Invalid Client Certificate", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientCert: []byte("not-a-client-cert"), + ClientKey: keyPem, + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "failed to create new EJBCA API client: failed to create new EJBCA API authenticator: failed to load client certificate", + }, + { + name: "Invalid Client Key", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientCert: authCertPem, + ClientKey: []byte("not-a-client-key"), + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "failed to create new EJBCA API client: failed to create new EJBCA API authenticator: failed to load client certificate", + }, + { + name: "No Token URL", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithOAuth(&OAuth{ + ClientID: "fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ", + ClientSecret: "1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H", + Scopes: "read:certificates,write:certificates", + Audience: "https://ejbca.example.com", + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "token URL is required", + }, + { + name: "No Client ID", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithOAuth(&OAuth{ + TokenURL: "https://dev.idp.com/oauth/token", + ClientSecret: "1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H", + Scopes: "read:certificates,write:certificates", + Audience: "https://ejbca.example.com", + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "client ID is required", + }, + { + name: "No Client Secret", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithOAuth(&OAuth{ + TokenURL: "https://dev.idp.com/oauth/token", + ClientID: "fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ", + Scopes: "read:certificates,write:certificates", + Audience: "https://ejbca.example.com", + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + }, + + expectError: true, + expectedErrorMessagePrefix: "client secret is required", + }, + } { + t.Run(tt.name, func(t *testing.T) { + var err error + if len(tt.opts) == 0 { + _, err = NewSigner(context.Background()) + } else { + _, err = NewSigner(context.Background(), tt.opts...) + } + + if tt.expectError { + t.Logf("\ntestcase[%d] and expected error:%+v\n", i, tt.expectedErrorMessagePrefix) + } else { + t.Logf("\ntestcase[%d] and no error expected\n", i) + } + + if tt.expectError { + assert.Error(t, err) + if err != nil && !strings.HasPrefix(err.Error(), tt.expectedErrorMessagePrefix) { + t.Errorf("expected error to start with %q, got %q", tt.expectedErrorMessagePrefix, err.Error()) + } + } + }) + } + + for _, tt := range []struct { + name string + opts []Option + + expectedSignerConfig *Config + }{ + { + name: "Cert Auth", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + WithChainDepth(5), + }, + + expectedSignerConfig: &Config{ + hostname: "ejbca.example.org", + caCertsBytes: append(serverCertPem, caCertPem...), + certAuth: &CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }, + certificateAuthorityName: "Root-CA", + certificateProfileName: "Server", + endEntityProfileName: "Server", + endEntityName: "cn", + chainDepth: 5, + }, + }, + { + name: "OAuth", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithOAuth(&OAuth{ + TokenURL: "https://dev.idp.com/oauth/token", + ClientID: "fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ", + ClientSecret: "1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H", + Scopes: "read:certificates,write:certificates", + Audience: "https://ejbca.example.com", + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + WithChainDepth(5), + }, + + expectedSignerConfig: &Config{ + hostname: "ejbca.example.org", + caCertsBytes: append(serverCertPem, caCertPem...), + oauth: &OAuth{ + TokenURL: "https://dev.idp.com/oauth/token", + ClientID: "fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ", + ClientSecret: "1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H", + Scopes: "read:certificates,write:certificates", + Audience: "https://ejbca.example.com", + }, + certificateAuthorityName: "Root-CA", + certificateProfileName: "Server", + endEntityProfileName: "Server", + endEntityName: "cn", + chainDepth: 5, + }, + }, + { + name: "EST", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithBasicAuth(&BasicAuth{ + Username: "user", + Password: "password", + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + WithESTAlias("TestAlias"), + WithChainDepth(5), + }, + + expectedSignerConfig: &Config{ + hostname: "ejbca.example.org", + caCertsBytes: append(serverCertPem, caCertPem...), + basicAuth: &BasicAuth{ + Username: "user", + Password: "password", + }, + certificateAuthorityName: "Root-CA", + certificateProfileName: "Server", + endEntityProfileName: "Server", + endEntityName: "cn", + estAliasName: "TestAlias", + chainDepth: 5, + }, + }, + { + name: "Override All Fields with Annotations", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + WithESTAlias("TestAlias"), + WithChainDepth(5), + WithAnnotations(map[string]string{ + ejbcaAnnotationPrefix + "certificateAuthorityName": "AnnotationTestCertificateAuthority", + ejbcaAnnotationPrefix + "certificateProfileName": "AnnotationTestCertificateProfile", + ejbcaAnnotationPrefix + "endEntityProfileName": "AnnotationTestEndEntityProfile", + ejbcaAnnotationPrefix + "endEntityName": "AnnotationTestEndEntity", + ejbcaAnnotationPrefix + "estAlias": "AnnotationTestAlias", + ejbcaAnnotationPrefix + "chainDepth": "2", + }), + }, + + expectedSignerConfig: &Config{ + hostname: "ejbca.example.org", + caCertsBytes: append(serverCertPem, caCertPem...), + certAuth: &CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }, + certificateAuthorityName: "AnnotationTestCertificateAuthority", + certificateProfileName: "AnnotationTestCertificateProfile", + endEntityProfileName: "AnnotationTestEndEntityProfile", + endEntityName: "AnnotationTestEndEntity", + estAliasName: "AnnotationTestAlias", + chainDepth: 2, + annotations: map[string]string{ + ejbcaAnnotationPrefix + "certificateAuthorityName": "AnnotationTestCertificateAuthority", + ejbcaAnnotationPrefix + "certificateProfileName": "AnnotationTestCertificateProfile", + ejbcaAnnotationPrefix + "endEntityProfileName": "AnnotationTestEndEntityProfile", + ejbcaAnnotationPrefix + "endEntityName": "AnnotationTestEndEntity", + ejbcaAnnotationPrefix + "estAlias": "AnnotationTestAlias", + ejbcaAnnotationPrefix + "chainDepth": "2", + }, + }, + }, + { + name: "Override All Fields with Deprecated Annotations", + opts: []Option{ + WithHostname("ejbca.example.org"), + WithCACerts(append(serverCertPem, caCertPem...)), + WithClientCert(&CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }), + WithCertificateAuthority("Root-CA"), + WithCertificateProfileName("Server"), + WithEndEntityProfileName("Server"), + WithEndEntityName("cn"), + WithESTAlias("TestAlias"), + WithChainDepth(5), + WithAnnotations(map[string]string{ + "certificateAuthorityName": "AnnotationTestCertificateAuthority", + "certificateProfileName": "AnnotationTestCertificateProfile", + "endEntityProfileName": "AnnotationTestEndEntityProfile", + "endEntityName": "AnnotationTestEndEntity", + "estAlias": "AnnotationTestAlias", + "chainDepth": "2", + }), + }, + + expectedSignerConfig: &Config{ + hostname: "ejbca.example.org", + caCertsBytes: append(serverCertPem, caCertPem...), + certAuth: &CertAuth{ + ClientCert: authCertPem, + ClientKey: keyPem, + }, + certificateAuthorityName: "AnnotationTestCertificateAuthority", + certificateProfileName: "AnnotationTestCertificateProfile", + endEntityProfileName: "AnnotationTestEndEntityProfile", + endEntityName: "AnnotationTestEndEntity", + estAliasName: "AnnotationTestAlias", + chainDepth: 2, + annotations: map[string]string{ + "certificateAuthorityName": "AnnotationTestCertificateAuthority", + "certificateProfileName": "AnnotationTestCertificateProfile", + "endEntityProfileName": "AnnotationTestEndEntityProfile", + "endEntityName": "AnnotationTestEndEntity", + "estAlias": "AnnotationTestAlias", + "chainDepth": "2", + }, + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + signer, err := newInternalSigner(context.Background(), tt.opts...) + require.NoError(t, err) + + actualConfig := signer.getConfig() + assert.Equal(t, tt.expectedSignerConfig, actualConfig) + }) + } +} + +func TestGetEndEntityName(t *testing.T) { + for _, tt := range []struct { + name string + + defaultEndEntityName string + + subject string + dnsNames []string + uris []string + ips []string + + expectedEndEntityName string + }{ + { + name: "defaultEndEntityName unset use cn", + defaultEndEntityName: "", + subject: "CN=purplecat.example.com", + dnsNames: []string{"reddog.example.com"}, + uris: []string{"https://blueelephant.example.com"}, + ips: []string{"192.168.1.1"}, + + expectedEndEntityName: "purplecat.example.com", + }, + { + name: "defaultEndEntityName unset use dns", + defaultEndEntityName: "", + subject: "", + dnsNames: []string{"reddog.example.com"}, + uris: []string{"https://blueelephant.example.com"}, + ips: []string{"192.168.1.1"}, + + expectedEndEntityName: "reddog.example.com", + }, + { + name: "defaultEndEntityName unset use uri", + defaultEndEntityName: "", + subject: "", + dnsNames: []string{""}, + uris: []string{"https://blueelephant.example.com"}, + ips: []string{"192.168.1.1"}, + + expectedEndEntityName: "https://blueelephant.example.com", + }, + { + name: "defaultEndEntityName unset use ip", + defaultEndEntityName: "", + subject: "", + dnsNames: []string{""}, + uris: []string{""}, + ips: []string{"192.168.1.1"}, + + expectedEndEntityName: "192.168.1.1", + }, + { + name: "defaultEndEntityName set use cn", + defaultEndEntityName: "cn", + subject: "CN=purplecat.example.com", + dnsNames: []string{"reddog.example.com"}, + uris: []string{"https://blueelephant.example.com"}, + ips: []string{"192.168.1.1"}, + + expectedEndEntityName: "purplecat.example.com", + }, + { + name: "defaultEndEntityName set use dns", + defaultEndEntityName: "dns", + subject: "CN=purplecat.example.com", + dnsNames: []string{"reddog.example.com"}, + uris: []string{"https://blueelephant.example.com"}, + ips: []string{"192.168.1.1"}, + + expectedEndEntityName: "reddog.example.com", + }, + { + name: "defaultEndEntityName set use uri", + defaultEndEntityName: "uri", + subject: "CN=purplecat.example.com", + dnsNames: []string{"reddog.example.com"}, + uris: []string{"https://blueelephant.example.com"}, + ips: []string{"192.168.1.1"}, + + expectedEndEntityName: "https://blueelephant.example.com", + }, + { + name: "defaultEndEntityName set use ip", + defaultEndEntityName: "ip", + subject: "CN=purplecat.example.com", + dnsNames: []string{"reddog.example.com"}, + uris: []string{"https://blueelephant.example.com"}, + ips: []string{"192.168.1.1"}, + + expectedEndEntityName: "192.168.1.1", + }, + { + name: "defaultEndEntityName set use custom", + defaultEndEntityName: "aNonStandardValue", + subject: "CN=purplecat.example.com", + dnsNames: []string{"reddog.example.com"}, + uris: []string{"https://blueelephant.example.com"}, + ips: []string{"192.168.1.1"}, + + expectedEndEntityName: "aNonStandardValue", + }, + } { + t.Run(tt.name, func(t *testing.T) { + signer := &signer{ + config: &Config{ + endEntityName: tt.defaultEndEntityName, + }, + } + + csr, err := generateCSR(tt.subject, tt.dnsNames, tt.uris, tt.ips) + require.NoError(t, err) + + endEntityName := signer.getEndEntityName(context.Background(), csr) + require.NoError(t, err) + require.Equal(t, tt.expectedEndEntityName, endEntityName) + }) + } +} + +func TestSign(t *testing.T) { + caCert, rootKey := issueTestCertificate(t, "Root-CA", nil, nil) + caCertPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw}) + + issuingCert, issuingKey := issueTestCertificate(t, "Sub-CA", caCert, rootKey) + issuingCertPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: issuingCert.Raw}) + + leafCert, _ := issueTestCertificate(t, "LeafCert", issuingCert, issuingKey) + leafCertPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafCert.Raw}) + + expectedLeafAndChain := append([]*x509.Certificate{leafCert}, issuingCert) + + for _, tt := range []struct { + name string + + certificateResponseFormat string + ejbcaStatusCode int + + // Request + caName string + endEntityProfileName string + certificateProfileName string + endEntityName string + accountBindingID string + + // Expected + errorExpected bool + expectedErrorMessagePrefix string + }{ + { + name: "Success PEM", + + certificateResponseFormat: "PEM", + ejbcaStatusCode: http.StatusOK, + + caName: "Fake-Sub-CA", + endEntityProfileName: "fakeSubEAP", + certificateProfileName: "fakeSubCACP", + endEntityName: "", + accountBindingID: "", + + errorExpected: false, + }, + { + name: "Success DER", + + certificateResponseFormat: "DER", + ejbcaStatusCode: http.StatusOK, + + caName: "Fake-Sub-CA", + endEntityProfileName: "fakeSubEAP", + certificateProfileName: "fakeSubCACP", + endEntityName: "", + accountBindingID: "", + + errorExpected: false, + }, + { + name: "EJBCA API error", + + certificateResponseFormat: "DER", + ejbcaStatusCode: http.StatusInternalServerError, + + caName: "Fake-Sub-CA", + endEntityProfileName: "fakeSubEAP", + certificateProfileName: "fakeSubCACP", + endEntityName: "", + accountBindingID: "", + + errorExpected: true, + expectedErrorMessagePrefix: "failed to enroll CSR - 500 Internal Server Error - EJBCA API returned error", + }, + } { + t.Run(tt.name, func(t *testing.T) { + cn := "ejbca.example.org" + + testServer := httptest.NewTLSServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + enrollRestRequest := ejbca.EnrollCertificateRestRequest{} + err := json.NewDecoder(r.Body).Decode(&enrollRestRequest) + require.NoError(t, err) + + // Perform assertions before fake enrollment + require.Equal(t, tt.caName, enrollRestRequest.GetCertificateAuthorityName()) + require.Equal(t, tt.endEntityProfileName, enrollRestRequest.GetEndEntityProfileName()) + require.Equal(t, tt.certificateProfileName, enrollRestRequest.GetCertificateProfileName()) + require.Equal(t, tt.accountBindingID, enrollRestRequest.GetAccountBindingId()) + require.Equal(t, cn, enrollRestRequest.GetUsername()) + + response := certificateRestResponseFromExpectedCerts(t, expectedLeafAndChain, []*x509.Certificate{caCert}, tt.certificateResponseFormat) + + w.Header().Add("Content-Type", "application/json") + w.WriteHeader(tt.ejbcaStatusCode) + err = json.NewEncoder(w).Encode(response) + require.NoError(t, err) + })) + defer testServer.Close() + + fakeClientConfig := fakeClientConfig{ + testServer: testServer, + } + + signer, err := NewSigner(context.Background(), + WithHostname(testServer.URL), + WithOAuth(&OAuth{ + TokenURL: "https://dev.idp.com/oauth/token", + ClientID: "fi3ElQUVoBBHyRNt4mpUxG9WY65AOCcJ", + ClientSecret: "1EXHdD7Ikmmv0OkBoJZZtzOG5iAzvwdqBVuvquf-QEvL6fLrEG_heJHphtEXVj9H", + Scopes: "read:certificates,write:certificates", + Audience: "https://ejbca.example.com", + }), + WithCertificateAuthority(tt.caName), + WithCertificateProfileName(tt.certificateProfileName), + WithEndEntityProfileName(tt.endEntityProfileName), + WithEndEntityName("cn"), + withAuthenticator(fakeClientConfig.newFakeAuthenticator), + ) + require.NoError(t, err) + + csrBytes, err := generateCSR("CN=ejbca.example.org", nil, nil, nil) + require.NoError(t, err) + csrPem := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrBytes.Raw}) + + leafAndCA, err := signer.Sign(context.Background(), csrPem) + if tt.errorExpected { + require.Error(t, err) + require.True(t, strings.HasPrefix(err.Error(), tt.expectedErrorMessagePrefix)) + return + } + + var expected []byte + expected = append(expected, leafCertPem...) + expected = append(expected, issuingCertPem...) + expected = append(expected, caCertPem...) + + require.NoError(t, err) + require.Equal(t, leafAndCA, expected) + }) + } +} + +func certificateRestResponseFromExpectedCerts(t *testing.T, leafCertAndChain []*x509.Certificate, rootCAs []*x509.Certificate, format string) *ejbca.CertificateRestResponse { + require.NotEqual(t, 0, len(leafCertAndChain)) + var issuingCa string + if format == "PEM" { + issuingCa = string(pem.EncodeToMemory(&pem.Block{Bytes: leafCertAndChain[0].Raw, Type: "CERTIFICATE"})) + } else { + issuingCa = base64.StdEncoding.EncodeToString(leafCertAndChain[0].Raw) + } + + var caChain []string + if format == "PEM" { + for _, cert := range leafCertAndChain[1:] { + caChain = append(caChain, string(pem.EncodeToMemory(&pem.Block{Bytes: cert.Raw, Type: "CERTIFICATE"}))) + } + for _, cert := range rootCAs { + caChain = append(caChain, string(pem.EncodeToMemory(&pem.Block{Bytes: cert.Raw, Type: "CERTIFICATE"}))) + } + } else { + for _, cert := range leafCertAndChain[1:] { + caChain = append(caChain, base64.StdEncoding.EncodeToString(cert.Raw)) + } + for _, cert := range rootCAs { + caChain = append(caChain, base64.StdEncoding.EncodeToString(cert.Raw)) + } + } + + response := &ejbca.CertificateRestResponse{} + response.SetResponseFormat(format) + response.SetCertificate(issuingCa) + response.SetCertificateChain(caChain) + return response +} + +func generateCSR(subject string, dnsNames []string, uris []string, ipAddresses []string) (*x509.CertificateRequest, error) { + keyBytes, _ := rsa.GenerateKey(rand.Reader, 2048) + + var name pkix.Name + + if subject != "" { + // 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 nil, errors.New("invalid subject") + } + + 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 + } + } + } + + template := x509.CertificateRequest{ + Subject: name, + 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, 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, 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, err + } + + parsedCSR, err := x509.ParseCertificateRequest(csrBytes) + if err != nil { + return nil, err + } + + return parsedCSR, nil +} + +func issueTestCertificate(t *testing.T, cn string, parent *x509.Certificate, signingKey any) (*x509.Certificate, *ecdsa.PrivateKey) { + var err error + var key *ecdsa.PrivateKey + now := time.Now() + + key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + publicKey := &key.PublicKey + signerPrivateKey := key + if signingKey != nil { + signerPrivateKey = signingKey.(*ecdsa.PrivateKey) + } + + serial, _ := rand.Int(rand.Reader, big.NewInt(1337)) + certTemplate := &x509.Certificate{ + Subject: pkix.Name{CommonName: cn}, + SerialNumber: serial, + BasicConstraintsValid: true, + IsCA: true, + NotBefore: now, + NotAfter: now.Add(time.Hour * 24), + } + + if parent == nil { + parent = certTemplate + } + + certData, err := x509.CreateCertificate(rand.Reader, certTemplate, parent, publicKey, signerPrivateKey) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(certData) + require.NoError(t, err) + + return cert, key +} diff --git a/internal/signer/est/est.go b/internal/est/est.go similarity index 90% rename from internal/signer/est/est.go rename to internal/est/est.go index 76a579f..fd98370 100644 --- a/internal/signer/est/est.go +++ b/internal/est/est.go @@ -63,7 +63,7 @@ func NewBuilder(hostname string) *Builder { return &Builder{ hostname: cleanHostname, client: http.DefaultClient, - errs: errs, + errs: errs, } } @@ -85,18 +85,18 @@ func (b *Builder) WithBasicAuth(username, password string) *Builder { return b } -func (c *Builder) WithCaCertificates(caCertificates []*x509.Certificate) *Builder { +func (b *Builder) WithCaCertificates(caCertificates []*x509.Certificate) *Builder { if caCertificates != nil { - c.caCertificates = caCertificates + b.caCertificates = caCertificates } - return c + return b } -func (c *Builder) WithClientCertificate(clientCertificate *tls.Certificate) *Builder { - c.clientCertificate = clientCertificate +func (b *Builder) WithClientCertificate(clientCertificate *tls.Certificate) *Builder { + b.clientCertificate = clientCertificate - return c + return b } func (b *Builder) WithDefaultESTAlias(alias string) *Builder { @@ -111,6 +111,7 @@ func (b *Builder) Build() (Client, error) { tlsConfig := &tls.Config{ Renegotiation: tls.RenegotiateOnceAsClient, + MinVersion: tls.VersionTLS12, } if b.clientCertificate != nil { @@ -179,6 +180,7 @@ func (e *client) CaCerts(alias string) ([]*x509.Certificate, error) { if err != nil { return nil, err } + defer getCaCertsRestResponse.Body.Close() if getCaCertsRestResponse.StatusCode != http.StatusOK { return nil, fmt.Errorf("unexpected status code: %d", getCaCertsRestResponse.StatusCode) @@ -191,7 +193,7 @@ func (e *client) CaCerts(alias string) ([]*x509.Certificate, error) { if len(content) > 0 { errMsg = fmt.Sprintf("unexpected content-type: %s", content[0]) } - return nil, fmt.Errorf(errMsg) + return nil, errors.New(errMsg) } // Ensure that the response is base64 encoded @@ -201,7 +203,7 @@ func (e *client) CaCerts(alias string) ([]*x509.Certificate, error) { if len(encoding) > 0 { errMsg = fmt.Sprintf("unexpected content-transfer-encoding: %s", encoding[0]) } - return nil, fmt.Errorf(errMsg) + return nil, errors.New(errMsg) } e.logger.Info("Validated HTTP response headers") @@ -265,6 +267,17 @@ func (e *client) SimpleEnroll(alias string, csr string) ([]*x509.Certificate, er } defer simpleEnrollRestResponse.Body.Close() + encodedBytes, err := io.ReadAll(simpleEnrollRestResponse.Body) + if err != nil { + return nil, err + } + + if simpleEnrollRestResponse.StatusCode != http.StatusOK { + err = fmt.Errorf("POST request to %s was unsuccessful [%s]: %s", url, simpleEnrollRestResponse.Status, string(encodedBytes)) + e.logger.Error(err, "") + return nil, err + } + // 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") { @@ -272,7 +285,7 @@ func (e *client) SimpleEnroll(alias string, csr string) ([]*x509.Certificate, er if len(content) > 0 { errMsg = fmt.Sprintf("unexpected content-type: %s", content[0]) } - return nil, fmt.Errorf(errMsg) + return nil, errors.New(errMsg) } // Ensure that the response is base64 encoded @@ -282,7 +295,9 @@ func (e *client) SimpleEnroll(alias string, csr string) ([]*x509.Certificate, er if len(encoding) > 0 { errMsg = fmt.Sprintf("unexpected content-transfer-encoding: %s", encoding[0]) } - return nil, fmt.Errorf(errMsg) + err = errors.New(errMsg) + e.logger.Error(err, "") + return nil, err } e.logger.Info("Validated HTTP response headers") @@ -291,14 +306,9 @@ func (e *client) SimpleEnroll(alias string, csr string) ([]*x509.Certificate, er 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) + return nil, fmt.Errorf("failed to decode PKCS#7 response from EST server: %w", err) } parsed, err := pkcs7.Parse(decodedBytes) @@ -321,9 +331,10 @@ func cleanHostname(hostname string) (string, error) { hostname = "https://" + hostname } - if u, err := url.Parse(hostname); err == nil { + u, err := url.Parse(hostname) + if err == nil { return u.Host, nil - } else { - return "", fmt.Errorf("EJBCA hostname is not a valid URL: %s", err) } + + return "", fmt.Errorf("EJBCA hostname is not a valid URL: %w", err) } diff --git a/internal/signer/est/est_test.go b/internal/est/est_test.go similarity index 70% rename from internal/signer/est/est_test.go rename to internal/est/est_test.go index 0bdf0ee..2bd1a4d 100644 --- a/internal/signer/est/est_test.go +++ b/internal/est/est_test.go @@ -85,9 +85,9 @@ func TestClient_SimpleEnrollSuccess(t *testing.T) { 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) - } + if err != nil { + t.Fatalf("Failed to write response: %v", err) + } } testServer := httptest.NewTLSServer(http.HandlerFunc(simpleEnrollResponder)) @@ -106,10 +106,10 @@ func TestClient_SimpleEnrollSuccess(t *testing.T) { 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()) - } + 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 { @@ -142,7 +142,7 @@ func TestClient_SimpleEnrollNoAliasSuccess(t *testing.T) { 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) + t.Fatalf("Expected URL path to be /.well-known/est/simpleenroll, got %s", r.URL.Path) } if r.Header.Get("Content-Type") != "application/pkcs10" { @@ -171,9 +171,9 @@ func TestClient_SimpleEnrollNoAliasSuccess(t *testing.T) { 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) - } + if err != nil { + t.Fatalf("Failed to write response: %v", err) + } } testServer := httptest.NewTLSServer(http.HandlerFunc(simpleEnrollResponder)) @@ -191,10 +191,10 @@ func TestClient_SimpleEnrollNoAliasSuccess(t *testing.T) { 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()) - } + 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 { @@ -219,63 +219,63 @@ func TestClient_SimpleEnrollFailure(t *testing.T) { 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()) - } - }) - } + testCases := []struct { + name string + handlerFunc func(w http.ResponseWriter, r *http.Request) + expectedError error + }{ + { + name: "InvalidContentType", + handlerFunc: func(w http.ResponseWriter, _ *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, _ *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) { @@ -301,9 +301,9 @@ func TestClient_CaCertsSuccess(t *testing.T) { 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) - } + if err != nil { + t.Fatalf("Failed to write response: %v", err) + } } testServer := httptest.NewTLSServer(http.HandlerFunc(caCertsResponder)) @@ -348,9 +348,9 @@ func TestClient_CaCertsNoAliasSuccess(t *testing.T) { 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) - } + 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") @@ -360,9 +360,9 @@ func TestClient_CaCertsNoAliasSuccess(t *testing.T) { 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) - } + if err != nil { + t.Fatalf("Failed to write response: %v", err) + } } testServer := httptest.NewTLSServer(http.HandlerFunc(caCertsResponder)) @@ -400,57 +400,57 @@ func TestClient_CaCertsNoAliasSuccess(t *testing.T) { 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()) - } - }) - } + testCases := []struct { + name string + handlerFunc func(w http.ResponseWriter, r *http.Request) + expectedError error + }{ + { + name: "InvalidContentType", + handlerFunc: func(w http.ResponseWriter, _ *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, _ *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) { @@ -501,12 +501,12 @@ func exportCertificateToB64Pkcs7(cert *x509.Certificate) []byte { return []byte(base64Str) } -func generateCSR(subject string, dnsNames []string, uris []string, ipAddresses []string) ([]byte, *x509.CertificateRequest, error) { +func generateCSR(subject string, dnsNames []string, uris []string, ipAddresses []string) ([]byte, error) { keyBytes, _ := rsa.GenerateKey(rand.Reader, 2048) subj, err := parseSubjectDN(subject) if err != nil { - return nil, nil, err + return nil, err } template := x509.CertificateRequest{ @@ -526,7 +526,7 @@ func generateCSR(subject string, dnsNames []string, uris []string, ipAddresses [ } uriPointer, err := url.Parse(u) if err != nil { - return nil, nil, err + return nil, err } uriPointers = append(uriPointers, uriPointer) } @@ -540,7 +540,7 @@ func generateCSR(subject string, dnsNames []string, uris []string, ipAddresses [ } ip := net.ParseIP(ipStr) if ip == nil { - return nil, nil, fmt.Errorf("invalid IP address: %s", ipStr) + return nil, fmt.Errorf("invalid IP address: %s", ipStr) } ipAddrs = append(ipAddrs, ip) } @@ -549,21 +549,16 @@ func generateCSR(subject string, dnsNames []string, uris []string, ipAddresses [ // Generate the CSR csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &template, keyBytes) if err != nil { - return nil, nil, err + return 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 nil, err } - return csrBuf.Bytes(), parsedCSR, nil + return csrBuf.Bytes(), nil } // Function that turns subject string into pkix.Name diff --git a/internal/signer/est/server_test.go b/internal/est/server_test.go similarity index 97% rename from internal/signer/est/server_test.go rename to internal/est/server_test.go index 0372f42..badb2df 100644 --- a/internal/signer/est/server_test.go +++ b/internal/est/server_test.go @@ -194,15 +194,16 @@ func (s *Server) Start() { } s.srv = &http.Server{ - Addr: s.address, - Handler: mux, + Addr: s.address, + Handler: mux, + ReadHeaderTimeout: 30, } - serveTlsService := s.tlsCert != "" && s.tlsKey != "" + serveTLSService := s.tlsCert != "" && s.tlsKey != "" go func() { log.Printf("starting REST server on address [%s]\n", s.address) - if serveTlsService { + if serveTLSService { if err := s.srv.ListenAndServeTLS(s.tlsCert, s.tlsKey); err != nil && !errors.Is(err, http.ErrServerClosed) { panic(err) } diff --git a/internal/signer/signer.go b/internal/signer/signer.go deleted file mode 100644 index a479191..0000000 --- a/internal/signer/signer.go +++ /dev/null @@ -1,729 +0,0 @@ -/* -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 signer - -import ( - "context" - "crypto/tls" - "crypto/x509" - "encoding/base64" - "encoding/pem" - "errors" - "fmt" - "math/rand" - "net/http" - "strconv" - - "github.com/Keyfactor/ejbca-go-client-sdk/api/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" - "sigs.k8s.io/controller-runtime/pkg/log" -) - -// ejbcaSigner implements both Signer and Builder interfaces -var _ Builder = &ejbcaSigner{} -var _ Signer = &ejbcaSigner{} - -const annotationPrefix = "ejbca-k8s-csr-signer.keyfactor.com/" - -type Builder interface { - Reset() Builder - WithContext(ctx context.Context) Builder - WithCredsSecret(corev1.Secret) Builder - WithConfigMap(corev1.ConfigMap) Builder - WithCACertConfigMap(corev1.ConfigMap) Builder - PreFlight() error - Build() Signer -} - -type Signer interface { - Sign(csr certificates.CertificateSigningRequest) ([]byte, error) -} - -type ejbcaSigner struct { - ctx context.Context - logger logr.Logger - creds corev1.Secret - - // Given from config - hostname string - defaultEndEntityName string - defaultCertificateProfileName string - defaultEndEntityProfileName string - defaultCertificateAuthorityName string - defaultESTAlias string - chainDepth int - - // Computed - errs []error - enrollWithEst bool - caChain []*x509.Certificate - preflightComplete bool - - estClient est.Client - restClient *ejbca.APIClient -} - -// NewEjbcaSignerBuilder returns a new Builder for the EJBCA signer -func NewEjbcaSignerBuilder() Builder { - return &ejbcaSigner{} -} - -// Reset resets the Builder to its initial state for reuse -func (s *ejbcaSigner) Reset() Builder { - s.errs = make([]error, 0) - s.enrollWithEst = false - return s -} - -// WithContext sets the context for the Builder -func (s *ejbcaSigner) WithContext(ctx context.Context) Builder { - s.ctx = ctx - s.logger = log.FromContext(ctx) - return s -} - -// WithCredsSecret sets the credentials secret for the Builder. If the secret is of type TLS, the signer will -// authenticate to the EJBCA API using a client certificate. If the secret is of type BasicAuth, the signer will -// authenticate to the EJBCA EST API using HTTP Basic Auth. -func (s *ejbcaSigner) WithCredsSecret(secret corev1.Secret) Builder { - if secret.Type == corev1.SecretTypeTLS { - // If we have a TLS secret, we will assume that we are not enrolling with EST and will authenticate to - // the EJBCA API using a client certificate - s.enrollWithEst = false - s.logger.Info("Found TLS secret. Using EJBCA REST") - - _, ok := secret.Data["tls.crt"] - if !ok { - s.errs = append(s.errs, errors.New("tls.crt not found in secret data")) - } - - _, ok = secret.Data["tls.key"] - if !ok { - s.errs = append(s.errs, errors.New("tls.key not found in secret data")) - } - } else if secret.Type == corev1.SecretTypeBasicAuth { - // If we have a non-TLS secret, we will assume that we are enrolling with EST and will authenticate to - // the EJBCA API using HTTP Basic Auth - s.enrollWithEst = true - s.logger.Info("Found BasicAuth secret. Using EJBCA EST") - - _, ok := secret.Data["username"] - if !ok { - s.errs = append(s.errs, errors.New("username not found in secret data")) - } - - _, ok = secret.Data["password"] - if !ok { - s.errs = append(s.errs, errors.New("password not found in secret data")) - } - } else { - s.errs = append(s.errs, errors.New("secret type is not TLS or BasicAuth")) - } - - s.creds = secret - return s -} - -// WithConfigMap sets the configuration config map for the Builder. -func (s *ejbcaSigner) WithConfigMap(config corev1.ConfigMap) Builder { - if host, ok := config.Data["ejbcaHostname"]; ok && host != "" { - s.hostname = config.Data["ejbcaHostname"] - } else { - s.errs = append(s.errs, errors.New("ejbcaHostname not found in config map data")) - } - - if defaultEndEntityName, ok := config.Data["defaultEndEntityName"]; ok && defaultEndEntityName != "" { - s.defaultEndEntityName = defaultEndEntityName - } - - if defaultCertificateProfileName, ok := config.Data["defaultCertificateProfileName"]; ok && defaultCertificateProfileName != "" { - s.defaultCertificateProfileName = defaultCertificateProfileName - } - - if defaultEndEntityProfileName, ok := config.Data["defaultEndEntityProfileName"]; ok && defaultEndEntityProfileName != "" { - s.defaultEndEntityProfileName = defaultEndEntityProfileName - } - - if defaultCertificateAuthorityName, ok := config.Data["defaultCertificateAuthorityName"]; ok && defaultCertificateAuthorityName != "" { - s.defaultCertificateAuthorityName = defaultCertificateAuthorityName - } - - if defaultESTAlias, ok := config.Data["defaultESTAlias"]; ok && defaultESTAlias != "" { - s.defaultESTAlias = defaultESTAlias - } - - if chainDepth, ok := config.Data["chainDepth"]; ok && chainDepth != "" { - var err error - s.chainDepth, err = strconv.Atoi(chainDepth) - if err != nil { - s.errs = append(s.errs, errors.New("chainDepth is not an integer")) - } - } - - return s -} - -// WithCACertConfigMap sets the CA certificate config map for the Builder. The CA certificate config map is optional. -func (s *ejbcaSigner) WithCACertConfigMap(config corev1.ConfigMap) Builder { - if len(config.Data) == 0 { - return s - } - - // There is no requirement that the CA certificate is stored under a specific key in the secret, so we can just - // iterate over the map and effectively set the caCertBytes to the last value in the map - var caCertBytes string - for _, caCertBytes = range config.Data { - } - - // Try to decode caCertBytes as a PEM formatted block - caChainBlocks, _ := util.DecodePEMBytes([]byte(caCertBytes)) - if len(caChainBlocks) > 0 { - var caChain []*x509.Certificate - for _, block := range caChainBlocks { - // Parse the PEM block into an x509 certificate - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - s.errs = append(s.errs, err) - return s - } - - caChain = append(caChain, cert) - } - - s.caChain = caChain - } - - s.logger.Info(fmt.Sprintf("Found %d CA certificates in the CA certificate config map", len(s.caChain))) - - return s -} - -// PreFlight performs preflight checks to ensure that the signer is ready to sign CSRs. -// If the builder is configured to sign certificates with the EJBCA EST API, this method will create an EJBCA EST client. -// If the builder is configured to sign certificates with the EJBCA REST API, this method will create an EJBCA REST client. -func (s *ejbcaSigner) PreFlight() error { - var err error - - // Configure the EJBCA API client - if s.enrollWithEst { - s.estClient, err = s.newEstClient() - if err != nil { - s.errs = append(s.errs, err) - } - } else { - s.restClient, err = s.newRestClient() - if err != nil { - s.errs = append(s.errs, err) - } - } - - s.logger.Info("Preflight complete") - s.preflightComplete = true - return utilerrors.NewAggregate(s.errs) -} - -// newRestClient creates a new EJBCA REST API client using the EJBCA Go Client SDK. -// It sets up the client to use the client certificate from the credentials secret -// and the CA certificate from the CA certificate config map. -func (s *ejbcaSigner) newRestClient() (*ejbca.APIClient, error) { - // Create EJBCA API Client - ejbcaConfig := ejbca.NewConfiguration() - - if ejbcaConfig.Host == "" { - ejbcaConfig.Host = s.hostname - } - - clientCertByte, ok := s.creds.Data["tls.crt"] - if !ok || len(clientCertByte) == 0 { - return nil, errors.New("tls.crt not found in secret data") - } - - // Try to decode client certificate as a PEM formatted block - clientCertPemBlock, clientKeyPemBlock := util.DecodePEMBytes(clientCertByte) - - // If clientCertPemBlock is empty, try to decode the certificate as a DER formatted block - if len(clientCertPemBlock) == 0 { - s.logger.Info("tls.crt does not appear to be PEM formatted. Attempting to decode as DER formatted block.") - // Try to b64 decode the DER formatted block, but don't error if it fails - clientCertBytes, err := base64.StdEncoding.DecodeString(string(clientCertByte)) - if err == nil { - clientCertPemBlock = append(clientCertPemBlock, &pem.Block{Type: "CERTIFICATE", Bytes: clientCertBytes}) - } else { - // If b64 decoding fails, assume the certificate is DER formatted - clientCertPemBlock = append(clientCertPemBlock, &pem.Block{Type: "CERTIFICATE", Bytes: clientCertByte}) - } - } - - // Determine if ejbcaCert contains a private key - clientCertContainsKey := false - if clientKeyPemBlock != nil { - clientCertContainsKey = true - } - - if !clientCertContainsKey { - clientKeyBytes, ok := s.creds.Data["tls.key"] - if !ok || len(clientKeyBytes) == 0 { - return nil, errors.New("tls.pem not found in secret data") - } - - // Try to decode client key as a PEM formatted block - _, tempKeyPemBlock := util.DecodePEMBytes(clientKeyBytes) - if tempKeyPemBlock != nil { - clientKeyPemBlock = tempKeyPemBlock - } else { - s.logger.Info("tls.key does not appear to be PEM formatted. Attempting to decode as DER formatted block.") - // Try to b64 decode the DER formatted block, but don't error if it fails - tempKeyBytes, err := base64.StdEncoding.DecodeString(string(clientKeyBytes)) - if err == nil { - clientKeyPemBlock = &pem.Block{Type: "PRIVATE KEY", Bytes: tempKeyBytes} - } else { - // If b64 decoding fails, assume the private key is DER formatted - clientKeyPemBlock = &pem.Block{Type: "PRIVATE KEY", Bytes: clientKeyBytes} - } - } - } - - // Create a TLS certificate object - tlsCert, err := tls.X509KeyPair(pem.EncodeToMemory(clientCertPemBlock[0]), pem.EncodeToMemory(clientKeyPemBlock)) - if err != nil { - return nil, err - } - - // Add the TLS certificate to the EJBCA configuration - ejbcaConfig.SetClientCertificate(&tlsCert) - - // If the CA certificate is provided, add it to the EJBCA configuration - ejbcaConfig.SetCaCertificates(s.caChain) - - s.logger.Info("Creating EJBCA REST API client") - - // Create EJBCA API Client - client, err := ejbca.NewAPIClient(ejbcaConfig) - if err != nil { - return nil, err - } - - return client, nil -} - -// 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() (est.Client, error) { - // Get username and password from secret - username, ok := s.creds.Data["username"] - if !ok { - return nil, errors.New("username not found in secret data") - } - - password, ok := s.creds.Data["password"] - if !ok { - return nil, errors.New("password not found in secret data") - } - - 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 { - return nil, fmt.Errorf("Error creating EST client: %s", err) - } - - return client, nil -} - -// Build builds the Signer from the Builder, but secretly returns the Builder since it implements -// the Signer interface as well. -func (s *ejbcaSigner) Build() Signer { - if !s.preflightComplete { - s.logger.Error(fmt.Errorf("preflight not complete"), "preflight must be completed before building signer") - return nil - } - - return s -} - -// Sign signs the given CSR using the configured EJBCA API client. -func (s *ejbcaSigner) Sign(csr certificates.CertificateSigningRequest) ([]byte, error) { - if s.enrollWithEst { - return s.signWithEst(&csr) - } else { - return s.signWithRest(&csr) - } -} - -// getEndEntityName determines the EJBCA end entity name based on the CSR and the defaultEndEntityName option. -func (s *ejbcaSigner) getEndEntityName(csr *x509.CertificateRequest) string { - eeName := "" - // 1. If the endEntityName option is set, determine the end entity name based on the option - // 2. If the endEntityName option is not set, determine the end entity name based on the CSR - - // cn: Use the CommonName from the CertificateRequest's DN - if s.defaultEndEntityName == "cn" || s.defaultEndEntityName == "" { - if csr.Subject.CommonName != "" { - eeName = csr.Subject.CommonName - s.logger.Info(fmt.Sprintf("Using CommonName from the CertificateRequest's DN as the EJBCA end entity name: %q", eeName)) - return eeName - } - } - - //* dns: Use the first DNSName from the CertificateRequest's DNSNames SANs - if s.defaultEndEntityName == "dns" || s.defaultEndEntityName == "" { - if len(csr.DNSNames) > 0 && csr.DNSNames[0] != "" { - eeName = csr.DNSNames[0] - s.logger.Info(fmt.Sprintf("Using the first DNSName from the CertificateRequest's DNSNames SANs as the EJBCA end entity name: %q", eeName)) - return eeName - } - } - - //* uri: Use the first URI from the CertificateRequest's URI Sans - if s.defaultEndEntityName == "uri" || s.defaultEndEntityName == "" { - if len(csr.URIs) > 0 { - eeName = csr.URIs[0].String() - s.logger.Info(fmt.Sprintf("Using the first URI from the CertificateRequest's URI Sans as the EJBCA end entity name: %q", eeName)) - return eeName - } - } - - //* ip: Use the first IPAddress from the CertificateRequest's IPAddresses SANs - if s.defaultEndEntityName == "ip" || s.defaultEndEntityName == "" { - if len(csr.IPAddresses) > 0 { - eeName = csr.IPAddresses[0].String() - s.logger.Info(fmt.Sprintf("Using the first IPAddress from the CertificateRequest's IPAddresses SANs as the EJBCA end entity name: %q", eeName)) - return eeName - } - } - - // End of defaults; if the endEntityName option is set to anything but cn, dns, or uri, use the option as the end entity name - if s.defaultEndEntityName != "" && s.defaultEndEntityName != "cn" && s.defaultEndEntityName != "dns" && s.defaultEndEntityName != "uri" { - eeName = s.defaultEndEntityName - s.logger.Info(fmt.Sprintf("Using the defaultEndEntityName as the EJBCA end entity name: %q", eeName)) - return eeName - } - - // If we get here, we were unable to determine the end entity name - s.logger.Error(fmt.Errorf("unsuccessfully determined end entity name"), fmt.Sprintf("the endEntityName option is set to %q, but no valid end entity name could be determined from the CertificateRequest", s.defaultEndEntityName)) - - return eeName -} - -// deprecatedAnnotationGetter is a helper function to get annotations that were -// specified without the ejbca-k8s-csr-signer.keyfactor.com/ prefix. -func (s *ejbcaSigner) deprecatedAnnotationGetter(annotations map[string]string, annotation string) string { - annotationValue, ok := annotations[annotation] - if ok { - s.logger.Info(fmt.Sprintf("Annotations specified without the %q prefix is deprecated and will be removed in the future. Using %q as %q", annotationPrefix, annotationValue, annotation)) - return annotationValue - } - - return "" -} - -// signWithRest sets up a request to the EJBCA REST API and enrolls the certificate. -func (s *ejbcaSigner) signWithRest(csr *certificates.CertificateSigningRequest) ([]byte, error) { - annotations := csr.GetAnnotations() - - parsedCsr, err := parseCSR(csr.Spec.Request) - if err != nil { - return nil, err - } - - // Log the common metadata of the CSR - s.logger.Info(fmt.Sprintf("Found CSR wtih DN %q and %d DNS SANs, %d IP SANs, and %d URI SANs", parsedCsr.Subject, len(parsedCsr.DNSNames), len(parsedCsr.IPAddresses), len(parsedCsr.URIs))) - - // Override the default end entity name if the annotation is set - endEntityName, ok := annotations[annotationPrefix+"endEntityName"] - if ok { - s.defaultEndEntityName = endEntityName - } else if endEntityName = s.deprecatedAnnotationGetter(annotations, "endEntityName"); endEntityName != "" { - s.defaultEndEntityName = endEntityName - } - - // Determine the EJBCA end entity name - ejbcaEeName := s.getEndEntityName(parsedCsr) - if ejbcaEeName == "" { - return nil, errors.New("failed to determine the EJBCA end entity name") - } - - s.logger.Info(fmt.Sprintf("Using or Creating EJBCA End Entity called %q", ejbcaEeName)) - - // Configure EJBCA PKCS#10 request - enroll := ejbca.EnrollCertificateRestRequest{ - CertificateProfileName: ptr(s.defaultCertificateProfileName), - EndEntityProfileName: ptr(s.defaultEndEntityProfileName), - CertificateAuthorityName: ptr(s.defaultCertificateAuthorityName), - Username: ptr(ejbcaEeName), - Password: ptr(randStringFromCharSet(20)), - IncludeChain: ptr(true), - } - - enroll.SetCertificateRequest(string(csr.Spec.Request)) - - certificateProfileName, ok := annotations[annotationPrefix+"certificateProfileName"] - if ok && certificateProfileName != "" { - s.logger.Info(fmt.Sprintf("Using the %q certificate profile name from CSR annotations", certificateProfileName)) - enroll.SetCertificateProfileName(certificateProfileName) - } else if certificateProfileName = s.deprecatedAnnotationGetter(annotations, "certificateProfileName"); certificateProfileName != "" { - s.logger.Info(fmt.Sprintf("Using the %q certificate profile name from CSR annotations", certificateProfileName)) - enroll.SetCertificateProfileName(certificateProfileName) - } - - endEntityProfileName, ok := annotations[annotationPrefix+"endEntityProfileName"] - if ok && endEntityProfileName != "" { - s.logger.Info(fmt.Sprintf("Using the %q end entity profile name from CSR annotations", endEntityProfileName)) - enroll.SetEndEntityProfileName(endEntityProfileName) - } else if endEntityProfileName = s.deprecatedAnnotationGetter(annotations, "endEntityProfileName"); endEntityProfileName != "" { - s.logger.Info(fmt.Sprintf("Using the %q end entity profile name from CSR annotations", endEntityProfileName)) - enroll.SetEndEntityProfileName(endEntityProfileName) - } - - certificateAuthorityName, ok := annotations[annotationPrefix+"certificateAuthorityName"] - if ok && certificateAuthorityName != "" { - s.logger.Info(fmt.Sprintf("Using the %q certificate authority from CSR annotations", certificateAuthorityName)) - enroll.SetCertificateAuthorityName(certificateAuthorityName) - } else if certificateAuthorityName = s.deprecatedAnnotationGetter(annotations, "certificateAuthorityName"); certificateAuthorityName != "" { - s.logger.Info(fmt.Sprintf("Using the %q certificate authority from CSR annotations", certificateAuthorityName)) - enroll.SetCertificateAuthorityName(certificateAuthorityName) - } - - chainDepthStr, ok := annotations[annotationPrefix+"chainDepth"] - if ok { - chainDepth, err := strconv.Atoi(chainDepthStr) - if err == nil { - s.logger.Info(fmt.Sprintf("Using \"%d\" as chain depth from annotation", chainDepth)) - s.chainDepth = chainDepth - } - } - - if enroll.GetCertificateProfileName() == "" { - return nil, errors.New("certificateProfileName was not found") - } - if enroll.GetEndEntityProfileName() == "" { - return nil, errors.New("endEntityProfileName was not found") - } - if enroll.GetCertificateAuthorityName() == "" { - return nil, errors.New("certificateAuthorityName was not found") - } - - s.logger.Info(fmt.Sprintf("Enrolling certificate with EJBCA with certificate profile name %q, end entity profile name %q, and certificate authority name %q", enroll.GetCertificateProfileName(), enroll.GetEndEntityProfileName(), enroll.GetCertificateAuthorityName())) - - // Enroll certificate - certificateObject, _, err := s.restClient.V1CertificateApi.EnrollPkcs10Certificate(context.Background()).EnrollCertificateRestRequest(enroll).Execute() - if err != nil { - detail := "error enrolling certificate with EJBCA. verify that the certificate profile name, end entity profile name, and certificate authority name are appropriate for the certificate request." - - var bodyError *ejbca.GenericOpenAPIError - ok = errors.As(err, &bodyError) - if ok { - detail += fmt.Sprintf(" - %s", string(bodyError.Body())) - } - - s.logger.Error(err, detail) - - return nil, fmt.Errorf(detail) - } - - leafAndChain, _, err := getCertificatesFromEjbcaObject(*certificateObject) - if err != nil { - s.logger.Error(err, fmt.Sprintf("error getting certificate from EJBCA response: %s", err.Error())) - return nil, err - } - - // Then, construct the PEM list according to chainDepth - - /* - chainDepth = 0 => whole chain - chainDepth = 1 => just the leaf - chainDepth = 2 => leaf + issuer - chainDepth = 3 => leaf + issuer + issuer - etc - */ - - // The two scenarios where we want the whole chain are when chainDepth is 0 or greater than the length of the whole chain - var pemChain []byte - if s.chainDepth == 0 || s.chainDepth > len(leafAndChain) { - s.chainDepth = len(leafAndChain) - } - for i := 0; i < s.chainDepth; i++ { - pemChain = append(pemChain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafAndChain[i].Raw})...) - } - - s.logger.Info(fmt.Sprintf("Successfully enrolled certificate with EJBCA and built leaf and chain to depth %d", s.chainDepth)) - - // Return the certificate and chain in PEM format - return pemChain, nil -} - -// parseCSR parses a PEM encoded PKCS#10 CSR to an x509.CertificateRequest object -func parseCSR(pemBytes []byte) (*x509.CertificateRequest, error) { - // extract PEM from request object - block, _ := pem.Decode(pemBytes) - if block == nil || block.Type != "CERTIFICATE REQUEST" { - return nil, errors.New("PEM block type must be CERTIFICATE REQUEST") - } - return x509.ParseCertificateRequest(block.Bytes) -} - -// getCertificatesFromEjbcaObject is a helper function to get the certificates from an EJBCA API response. -func getCertificatesFromEjbcaObject(ejbcaCert ejbca.CertificateRestResponse) ([]*x509.Certificate, bool, error) { - var certBytes []byte - var err error - certChainFound := false - - if ejbcaCert.GetResponseFormat() == "PEM" { - // Extract the certificate from the PEM string - block, _ := pem.Decode([]byte(ejbcaCert.GetCertificate())) - if block == nil { - return nil, false, errors.New("failed to parse certificate PEM") - } - certBytes = block.Bytes - } else if ejbcaCert.GetResponseFormat() == "DER" { - // Depending on how the EJBCA API was called, the certificate will either be single b64 encoded or double b64 encoded - // Try to decode the certificate twice, but don't exit if we fail here. The certificate is decoded later which - // will give more insight into the failure. - bytes := []byte(ejbcaCert.GetCertificate()) - for i := 0; i < 2; i++ { - var tempBytes []byte - tempBytes, err = base64.StdEncoding.DecodeString(string(bytes)) - if err == nil { - bytes = tempBytes - } - } - certBytes = append(certBytes, bytes...) - - // If the certificate chain is present, append it to the certificate bytes - if len(ejbcaCert.GetCertificateChain()) > 0 { - var chainCertBytes []byte - - certChainFound = true - for _, chainCert := range ejbcaCert.GetCertificateChain() { - // Depending on how the EJBCA API was called, the certificate will either be single b64 encoded or double b64 encoded - // Try to decode the certificate twice, but don't exit if we fail here. The certificate is decoded later which - // will give more insight into the failure. - for i := 0; i < 2; i++ { - var tempBytes []byte - tempBytes, err = base64.StdEncoding.DecodeString(chainCert) - if err == nil { - chainCertBytes = tempBytes - } - } - - certBytes = append(certBytes, chainCertBytes...) - } - } - } else { - return nil, false, errors.New("ejbca returned unknown certificate format: " + ejbcaCert.GetResponseFormat()) - } - - certs, err := x509.ParseCertificates(certBytes) - if err != nil { - return nil, false, err - } - - return certs, certChainFound, nil -} - -// signWithEst sets up a request to the EJBCA EST API and enrolls the certificate. -func (s *ejbcaSigner) signWithEst(csr *certificates.CertificateSigningRequest) ([]byte, error) { - annotations := csr.GetAnnotations() - alias := "" // Default is already set in the EST client - - // Get alias from object annotations, if they exist - a, ok := annotations[annotationPrefix+"estAlias"] - if ok { - alias = a - s.logger.Info("Using \"%s\" as EST alias from annotation", alias) - } else if a = s.deprecatedAnnotationGetter(annotations, "estAlias"); a != "" { - alias = a - } else { - s.logger.Info("No EST alias found in annotations, using default.") - } - - a, ok = annotations[annotationPrefix+"chainDepth"] - if ok { - chainDepth, err := strconv.Atoi(a) - if err == nil { - s.logger.Info(fmt.Sprintf("Using \"%d\" as chain depth from annotation", chainDepth)) - s.chainDepth = chainDepth - } - } - - // Decode PEM encoded PKCS#10 CSR to DER - block, _ := pem.Decode(csr.Spec.Request) - - // Enroll CSR with simpleenroll - 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.CaCerts(alias) - if err != nil { - return nil, err - } - - /* - chainDepth = 0 => whole chain - chainDepth = 1 => just the leaf - chainDepth = 2 => leaf + issuer - chainDepth = 3 => leaf + issuer + issuer - etc - */ - - // Build a list of the leaf and the whole chain - var leafAndChain []*x509.Certificate - leafAndChain = append(leafAndChain, leaf[0]) - leafAndChain = append(leafAndChain, chain...) - - // The two scenarios where we want the whole chain are when chainDepth is 0 or greater than the length of the whole chain - // IE if chainDepth == len(leafAndChain), the whole chain will be appended anyway - var pemChain []byte - if s.chainDepth == 0 || s.chainDepth > len(leafAndChain) { - s.chainDepth = len(leafAndChain) - } - for i := 0; i < s.chainDepth; i++ { - pemChain = append(pemChain, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafAndChain[i].Raw})...) - } - - s.logger.Info(fmt.Sprintf("Successfully enrolled certificate with EJBCA and built leaf and chain to depth %d", s.chainDepth)) - - return pemChain, nil -} - -// ptr is a helper function to return a pointer to a value -func ptr[T any](v T) *T { - return &v -} - -// From https://github.com/hashicorp/terraform-plugin-sdk/blob/v2.10.0/helper/acctest/random.go#L51 -// randStringFromCharSet generates a random string of a given length from a given character set. -func randStringFromCharSet(strlen int) string { - charSet := "abcdefghijklmnopqrstuvwxyz012346789" - result := make([]byte, strlen) - for i := 0; i < strlen; i++ { - result[i] = charSet[rand.Intn(len(charSet))] - } - return string(result) -} diff --git a/internal/signer/signer_test.go b/internal/signer/signer_test.go deleted file mode 100644 index e368a93..0000000 --- a/internal/signer/signer_test.go +++ /dev/null @@ -1,982 +0,0 @@ -/* -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 signer - -import ( - "bytes" - "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/asn1" - "encoding/pem" - "errors" - "fmt" - "github.com/Keyfactor/ejbca-k8s-csr-signer/pkg/util" - logrtesting "github.com/go-logr/logr/testr" - "github.com/stretchr/testify/assert" - certificates "k8s.io/api/certificates/v1" - corev1 "k8s.io/api/core/v1" - utilerrors "k8s.io/apimachinery/pkg/util/errors" - "math/big" - mathrand "math/rand" - "net" - "net/url" - "os" - ctrl "sigs.k8s.io/controller-runtime" - "strings" - "testing" - "time" -) - -func TestNewEjbcaSignerBuilder(t *testing.T) { - signer := NewEjbcaSignerBuilder() - if signer == nil { - t.Error("NewEjbcaSignerBuilder() should not return nil") - } -} - -func TestEjbcaSignerBuilder(t *testing.T) { - signer := &ejbcaSigner{} - - t.Run("WithContext", func(t *testing.T) { - ctx := ctrl.LoggerInto(context.TODO(), logrtesting.New(t)) - signer.WithContext(ctx) - - if signer.ctx != ctx { - t.Error("WithContext() should set the context") - } - - if !signer.logger.Enabled() { - t.Error("Expected logger to be enabled") - } - }) - - t.Run("WithCredsSecret", func(t *testing.T) { - t.Run("REST", func(t *testing.T) { - signer.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - secret := corev1.Secret{ - Type: corev1.SecretTypeTLS, - } - - t.Run("Fail", func(t *testing.T) { - signer.WithCredsSecret(secret) - - if signer.enrollWithEst { - t.Error("enrollWithEst should be false") - } - - if len(signer.errs) == 0 { - t.Error("Expected errors since secret is empty") - } - }) - - // Clear errors and config - signer.Reset() - - t.Run("Success", func(t *testing.T) { - signer.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - secret.Data = map[string][]byte{ - "tls.crt": []byte("public key"), - "tls.key": []byte("private key"), - } - - if signer.enrollWithEst { - t.Error("enrollWithEst should be false") - } - - signer.WithCredsSecret(secret) - - if len(signer.errs) != 0 { - t.Error("Expected no errors since secret is not empty") - } - }) - }) - - t.Run("EST", func(t *testing.T) { - secret := corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - } - - t.Run("Fail", func(t *testing.T) { - signer.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - signer.WithCredsSecret(secret) - - if !signer.enrollWithEst { - t.Error("enrollWithEst should be true") - } - - if len(signer.errs) == 0 { - t.Error("Expected errors since secret is empty") - } - }) - - // Clear errors and config - signer.Reset() - - t.Run("Success", func(t *testing.T) { - signer.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - secret.Data = map[string][]byte{ - "username": []byte("username"), - "password": []byte("password"), - } - - signer.WithCredsSecret(secret) - - if !signer.enrollWithEst { - t.Error("enrollWithEst should be true") - } - - if len(signer.errs) != 0 { - t.Error("Expected no errors since secret is not empty") - } - }) - }) - }) - - t.Run("WithConfigMap", func(t *testing.T) { - config := corev1.ConfigMap{} - - t.Run("Fail", func(t *testing.T) { - signer.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - signer.WithConfigMap(config) - - if len(signer.errs) == 0 { - t.Error("Expected errors since config is empty") - } - }) - - // Clear errors and config - signer.Reset() - - t.Run("chainDepth_not_digit", func(t *testing.T) { - signer.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - config.Data = map[string]string{ - "chainDepth": "not a digit", - } - - signer.WithConfigMap(config) - - if len(signer.errs) == 0 { - t.Error("Expected errors since chainDepth is not a digit") - } - }) - - // Clear errors and config - signer.Reset() - - t.Run("Success", func(t *testing.T) { - signer.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - config.Data = map[string]string{ - "ejbcaHostname": "fake-hostname.ejbca.org", - "defaultEndEntityName": "cn", - "defaultCertificateProfileName": "FakeCertProfile", - "defaultEndEntityProfileName": "FakeEndEntityProfile", - "defaultCertificateAuthorityName": "FakeCAName", - "defaultESTAlias": "FakeESTAlias", - "chainDepth": "2", - } - - signer.WithConfigMap(config) - - if len(signer.errs) != 0 { - t.Error("Expected no errors since config is not empty") - } - - assert.Equal(t, "fake-hostname.ejbca.org", signer.hostname) - assert.Equal(t, "cn", signer.defaultEndEntityName) - assert.Equal(t, "FakeCertProfile", signer.defaultCertificateProfileName) - assert.Equal(t, "FakeEndEntityProfile", signer.defaultEndEntityProfileName) - assert.Equal(t, "FakeCAName", signer.defaultCertificateAuthorityName) - assert.Equal(t, "FakeESTAlias", signer.defaultESTAlias) - assert.Equal(t, 2, signer.chainDepth) - }) - }) - - t.Run("WithCACertConfigMap", func(t *testing.T) { - caConfig := corev1.ConfigMap{} - - t.Run("InvalidCert", func(t *testing.T) { - signer.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - caConfig.Data = map[string]string{ - "caCert.crt": "invalid cert", - } - - signer.WithCACertConfigMap(caConfig) - - if len(signer.caChain) != 0 { - t.Error("Expected no CA chain since cert is invalid") - } - }) - - // Clear errors and config - signer.Reset() - - t.Run("Success", func(t *testing.T) { - signer.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - certificate, err := generateSelfSignedCertificate() - if err != nil { - t.Fatalf("Failed to generate self-signed certificate: %v", err) - } - certBytes, err := util.CompileCertificatesToPemBytes([]*x509.Certificate{certificate}) - if err != nil { - t.Fatalf("Failed to compile certificate to PEM bytes: %v", err) - } - - caConfig.Data = map[string]string{ - "caCert.crt": string(certBytes), - } - - signer.WithCACertConfigMap(caConfig) - - if len(signer.caChain) != 1 { - t.Error("Expected CA chain to have one certificate") - } - - if len(signer.errs) != 0 { - t.Error("Expected no errors since config is not empty") - } - }) - }) -} - -func TestEjbcaSigner(t *testing.T) { - ejbcaConfig := EjbcaTestConfig{} - err := ejbcaConfig.Get(t) - if err != nil { - t.Fatal(err) - } - - signerConfig := corev1.ConfigMap{ - Data: map[string]string{ - "ejbcaHostname": ejbcaConfig.hostname, - "defaultEndEntityName": "", - "defaultCertificateProfileName": ejbcaConfig.ejbcaCertificateProfileName, - "defaultEndEntityProfileName": ejbcaConfig.ejbcaEndEntityProfileName, - "defaultCertificateAuthorityName": ejbcaConfig.ejbcaCaName, - "defaultESTAlias": ejbcaConfig.estAlias, - "chainDepth": "0", - }, - } - - caConfig := corev1.ConfigMap{ - Data: map[string]string{ - "caCert.crt": string(ejbcaConfig.caCertBytes), - }, - } - - t.Run("REST", func(t *testing.T) { - restCreds := corev1.Secret{ - Type: corev1.SecretTypeTLS, - Data: map[string][]byte{ - "tls.crt": ejbcaConfig.clientCertBytes, - "tls.key": ejbcaConfig.clientKeyBytes, - }, - } - - // Build the signer - builder := &ejbcaSigner{} - builder. - WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))). - WithCredsSecret(restCreds). - WithConfigMap(signerConfig). - WithCACertConfigMap(caConfig) - - err = builder.PreFlight() - if err != nil { - t.Fatalf("Failed to preflight signer: %v", err) - } - - signer := builder.Build() - - // Generate a CSR - csr, _, err := generateCSR(ejbcaConfig.ejbcaCsrDn, []string{}, []string{}, []string{}) - if err != nil { - t.Fatalf("Failed to generate CSR: %v", err) - } - request := certificates.CertificateSigningRequest{ - Spec: certificates.CertificateSigningRequestSpec{ - Request: csr, - }, - } - - signedCertBytes, err := signer.Sign(request) - if err != nil { - t.Errorf("Failed to sign CSR: %v", err) - } - - // Verify the signed certificate - certBlock, _ := util.DecodePEMBytes(signedCertBytes) - if len(certBlock) == 0 { - t.Error("Failed to decode signed certificate") - } - - cert, err := x509.ParseCertificate(certBlock[0].Bytes) - if err != nil { - t.Errorf("Failed to parse signed certificate: %v", err) - } - - if cert.Subject.String() != ejbcaConfig.ejbcaCsrDn { - t.Error("Signed certificate subject does not match CSR subject") - } - }) - - t.Run("EST", func(t *testing.T) { - estCreds := corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - Data: map[string][]byte{ - "username": []byte(ejbcaConfig.estUsername), - "password": []byte(ejbcaConfig.estPassword), - }, - } - - // Build the signer - builder := &ejbcaSigner{} - builder. - WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))). - WithCredsSecret(estCreds). - WithConfigMap(signerConfig). - WithCACertConfigMap(caConfig) - - err = builder.PreFlight() - if err != nil { - t.Fatalf("Failed to preflight signer: %v", err) - } - - signer := builder.Build() - - // Generate a CSR - csr, _, err := generateCSR(ejbcaConfig.ejbcaCsrDn, []string{}, []string{}, []string{}) - if err != nil { - t.Fatalf("Failed to generate CSR: %v", err) - } - request := certificates.CertificateSigningRequest{ - Spec: certificates.CertificateSigningRequestSpec{ - Request: csr, - }, - } - - signedCertBytes, err := signer.Sign(request) - if err != nil { - t.Fatalf("Failed to sign CSR: %v", err) - } - - // Verify the signed certificate - certBlock, _ := util.DecodePEMBytes(signedCertBytes) - if len(certBlock) == 0 { - t.Fatalf("Failed to decode signed certificate") - } - - cert, err := x509.ParseCertificate(certBlock[0].Bytes) - if err != nil { - t.Fatalf("Failed to parse signed certificate: %v", err) - } - - if cert.Subject.String() != ejbcaConfig.ejbcaCsrDn { - t.Error("Signed certificate subject does not match CSR subject") - } - }) - - // Create supported annotations - supportedAnnotations := map[string]string{ - "ejbca-k8s-csr-signer.keyfactor.com/certificateAuthorityName": ejbcaConfig.ejbcaCaName, - "ejbca-k8s-csr-signer.keyfactor.com/certificateProfileName": ejbcaConfig.ejbcaCertificateProfileName, - "ejbca-k8s-csr-signer.keyfactor.com/endEntityName": "", - "ejbca-k8s-csr-signer.keyfactor.com/endEntityProfileName": ejbcaConfig.ejbcaEndEntityProfileName, - "ejbca-k8s-csr-signer.keyfactor.com/estAlias": ejbcaConfig.estAlias, - "ejbca-k8s-csr-signer.keyfactor.com/chainDepth": "5", - } - - // Create deprecated annotations - deprecatedAnnotations := map[string]string{ - "certificateAuthorityName": ejbcaConfig.ejbcaCaName, - "certificateProfileName": ejbcaConfig.ejbcaCertificateProfileName, - "endEntityName": "", - "endEntityProfileName": ejbcaConfig.ejbcaEndEntityProfileName, - "estAlias": ejbcaConfig.estAlias, - "chainDepth": "5", - } - - t.Run("RESTWithAnnotations", func(t *testing.T) { - testRestWithAnnotations := func(t *testing.T, annotations map[string]string) { - restCreds := corev1.Secret{ - Type: corev1.SecretTypeTLS, - Data: map[string][]byte{ - "tls.crt": ejbcaConfig.clientCertBytes, - "tls.key": ejbcaConfig.clientKeyBytes, - }, - } - - // Clear out existing config for annotation override - signerConfig = corev1.ConfigMap{ - Data: map[string]string{ - "ejbcaHostname": ejbcaConfig.hostname, - }, - } - - // Build the signer - builder := &ejbcaSigner{} - builder. - WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))). - WithCredsSecret(restCreds). - WithConfigMap(signerConfig). - WithCACertConfigMap(caConfig) - - err = builder.PreFlight() - if err != nil { - t.Fatalf("Failed to preflight signer: %v", err) - } - - signer := builder.Build() - - // Generate a CSR - csr, _, err := generateCSR(ejbcaConfig.ejbcaCsrDn, []string{}, []string{}, []string{}) - if err != nil { - t.Fatalf("Failed to generate CSR: %v", err) - } - request := certificates.CertificateSigningRequest{ - Spec: certificates.CertificateSigningRequestSpec{ - Request: csr, - }, - } - - request.SetAnnotations(annotations) - - signedCertBytes, err := signer.Sign(request) - if err != nil { - t.Fatalf("Failed to sign CSR: %v", err) - } - - // Verify the signed certificate - certBlock, _ := util.DecodePEMBytes(signedCertBytes) - if len(certBlock) == 0 { - t.Error("Failed to decode signed certificate") - } - - cert, err := x509.ParseCertificate(certBlock[0].Bytes) - if err != nil { - t.Errorf("Failed to parse signed certificate: %v", err) - } - - if cert.Subject.String() != ejbcaConfig.ejbcaCsrDn { - t.Error("Signed certificate subject does not match CSR subject") - } - } - - t.Run("Supported", func(t *testing.T) { - testRestWithAnnotations(t, supportedAnnotations) - }) - - t.Run("Deprecated", func(t *testing.T) { - testRestWithAnnotations(t, deprecatedAnnotations) - }) - }) - - t.Run("ESTWithAnnotations", func(t *testing.T) { - testEstWithAnnotations := func(t *testing.T, annotations map[string]string) { - estCreds := corev1.Secret{ - Type: corev1.SecretTypeBasicAuth, - Data: map[string][]byte{ - "username": []byte(ejbcaConfig.estUsername), - "password": []byte(ejbcaConfig.estPassword), - }, - } - - // Clear out existing config for annotation override - signerConfig = corev1.ConfigMap{ - Data: map[string]string{ - "ejbcaHostname": ejbcaConfig.hostname, - }, - } - - // Build the signer - builder := &ejbcaSigner{} - builder. - WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))). - WithCredsSecret(estCreds). - WithConfigMap(signerConfig). - WithCACertConfigMap(caConfig) - - err = builder.PreFlight() - if err != nil { - t.Fatalf("Failed to preflight signer: %v", err) - } - - signer := builder.Build() - - // Generate a CSR - csr, _, err := generateCSR(ejbcaConfig.ejbcaCsrDn, []string{}, []string{}, []string{}) - if err != nil { - t.Fatalf("Failed to generate CSR: %v", err) - } - request := certificates.CertificateSigningRequest{ - Spec: certificates.CertificateSigningRequestSpec{ - Request: csr, - }, - } - - request.SetAnnotations(annotations) - - signedCertBytes, err := signer.Sign(request) - if err != nil { - t.Fatalf("Failed to sign CSR: %v", err) - } - - // Verify the signed certificate - certBlock, _ := util.DecodePEMBytes(signedCertBytes) - if len(certBlock) == 0 { - t.Fatalf("Failed to decode signed certificate") - } - - cert, err := x509.ParseCertificate(certBlock[0].Bytes) - if err != nil { - t.Fatalf("Failed to parse signed certificate: %v", err) - } - - if cert.Subject.String() != ejbcaConfig.ejbcaCsrDn { - t.Error("Signed certificate subject does not match CSR subject") - } - } - - t.Run("Supported", func(t *testing.T) { - testEstWithAnnotations(t, supportedAnnotations) - }) - - t.Run("Deprecated", func(t *testing.T) { - testEstWithAnnotations(t, deprecatedAnnotations) - }) - }) - - // Test the default end entity name conditionals - t.Run("DefaultEndEntityNameTests", func(t *testing.T) { - builder := &ejbcaSigner{} - - // Test when endEntityName is not set - t.Run("endEntityName is not set", func(t *testing.T) { - builder.defaultEndEntityName = "" - - t.Run("CN", func(t *testing.T) { - builder.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - // Generate a CSR - _, csr, err := generateCSR("CN=purplecat.example.com", []string{"reddog.example.com"}, []string{"https://blueelephant.example.com"}, []string{"192.168.1.1"}) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, "purplecat.example.com", builder.getEndEntityName(csr)) - }) - - t.Run("DNS", func(t *testing.T) { - builder.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - // Generate a CSR - _, csr, err := generateCSR("", []string{"reddog.example.com"}, []string{"https://blueelephant.example.com"}, []string{"192.168.1.1"}) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, builder.getEndEntityName(csr), "reddog.example.com") - }) - - t.Run("URI", func(t *testing.T) { - builder.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - // Generate a CSR - _, csr, err := generateCSR("", []string{""}, []string{"https://blueelephant.example.com"}, []string{"192.168.1.1"}) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, "https://blueelephant.example.com", builder.getEndEntityName(csr)) - }) - - t.Run("IP", func(t *testing.T) { - builder.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - // Generate a CSR - _, csr, err := generateCSR("", []string{""}, []string{""}, []string{"192.168.1.1"}) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, builder.getEndEntityName(csr), "192.168.1.1") - }) - }) - - // Test when endEntityName is set - t.Run("endEntityName is set", func(t *testing.T) { - t.Run("CN", func(t *testing.T) { - builder.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - builder.defaultEndEntityName = "cn" - - // Generate a CSR - _, csr, err := generateCSR("CN=purplecat.example.com", []string{"reddog.example.com"}, []string{"https://blueelephant.example.com"}, []string{"192.168.1.1"}) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, builder.getEndEntityName(csr), "purplecat.example.com") - }) - - t.Run("DNS", func(t *testing.T) { - builder.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - builder.defaultEndEntityName = "dns" - - // Generate a CSR - _, csr, err := generateCSR("CN=purplecat.example.com", []string{"reddog.example.com"}, []string{"https://blueelephant.example.com"}, []string{"192.168.1.1"}) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, builder.getEndEntityName(csr), "reddog.example.com") - }) - - t.Run("URI", func(t *testing.T) { - builder.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - builder.defaultEndEntityName = "uri" - - // Generate a CSR - _, csr, err := generateCSR("CN=purplecat.example.com", []string{"reddog.example.com"}, []string{"https://blueelephant.example.com"}, []string{"192.168.1.1"}) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, builder.getEndEntityName(csr), "https://blueelephant.example.com") - }) - - t.Run("IP", func(t *testing.T) { - builder.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - builder.defaultEndEntityName = "ip" - - // Generate a CSR - _, csr, err := generateCSR("CN=purplecat.example.com", []string{"reddog.example.com"}, []string{"https://blueelephant.example.com"}, []string{"192.168.1.1"}) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, builder.getEndEntityName(csr), "192.168.1.1") - }) - - t.Run("endEntityName", func(t *testing.T) { - builder.WithContext(ctrl.LoggerInto(context.TODO(), logrtesting.New(t))) - - builder.defaultEndEntityName = "Hello World!" - - // Generate a CSR - _, csr, err := generateCSR("CN=purplecat.example.com", []string{"reddog.example.com"}, []string{"https://blueelephant.example.com"}, []string{"192.168.1.1"}) - if err != nil { - t.Fatal(err) - } - - assert.Equal(t, builder.getEndEntityName(csr), "Hello World!") - }) - }) - }) -} - -type EjbcaTestConfig struct { - hostname string - ejbcaCaName string - ejbcaCertificateProfileName string - ejbcaEndEntityProfileName string - ejbcaCsrDn string - estAlias string - - clientCertBytes []byte - clientKeyBytes []byte - caCertBytes []byte - - estUsername string - estPassword string -} - -func (c *EjbcaTestConfig) Get(t *testing.T) error { - var errs []error - - // Paths - pathToClientCert := os.Getenv("EJBCA_CLIENT_CERT_PATH") - pathToCaCert := os.Getenv("EJBCA_CA_CERT_PATH") - - // EJBCA Config - c.hostname = os.Getenv("EJBCA_HOSTNAME") - c.ejbcaCaName = os.Getenv("EJBCA_CA_NAME") - c.ejbcaCertificateProfileName = os.Getenv("EJBCA_CERTIFICATE_PROFILE_NAME") - c.ejbcaEndEntityProfileName = os.Getenv("EJBCA_END_ENTITY_PROFILE_NAME") - c.ejbcaCsrDn = os.Getenv("EJBCA_CSR_SUBJECT") - c.estAlias = os.Getenv("EJBCA_EST_ALIAS") - c.estUsername = os.Getenv("EJBCA_EST_USERNAME") - c.estPassword = os.Getenv("EJBCA_EST_PASSWORD") - - if pathToClientCert == "" { - err := errors.New("EJBCA_CLIENT_CERT_PATH environment variable is not set") - t.Error(err) - errs = append(errs, err) - } - - if pathToCaCert == "" { - err := errors.New("EJBCA_CA_CERT_PATH environment variable is not set") - t.Error(err) - errs = append(errs, err) - } - - if c.hostname == "" { - err := errors.New("EJBCA_HOSTNAME environment variable is not set") - t.Error(err) - errs = append(errs, err) - } - - if c.ejbcaCaName == "" { - err := errors.New("EJBCA_CA_NAME environment variable is not set") - t.Error(err) - errs = append(errs, err) - } - - if c.ejbcaCertificateProfileName == "" { - err := errors.New("EJBCA_CERTIFICATE_PROFILE_NAME environment variable is not set") - t.Error(err) - errs = append(errs, err) - } - - if c.ejbcaEndEntityProfileName == "" { - err := errors.New("EJBCA_END_ENTITY_PROFILE_NAME environment variable is not set") - t.Error(err) - errs = append(errs, err) - } - - if c.ejbcaCsrDn == "" { - err := errors.New("EJBCA_CSR_SUBJECT environment variable is not set") - t.Error(err) - errs = append(errs, err) - } - - if c.estAlias == "" { - err := errors.New("EJBCA_EST_ALIAS environment variable is not set") - t.Error(err) - errs = append(errs, err) - } - - if c.estUsername == "" { - err := errors.New("EJBCA_EST_USERNAME environment variable is not set") - t.Error(err) - errs = append(errs, err) - } - - if c.estPassword == "" { - err := errors.New("EJBCA_EST_PASSWORD environment variable is not set") - t.Error(err) - errs = append(errs, err) - } - - // Read the client cert and key from the file system. - clientCertBytes, err := os.ReadFile(pathToClientCert) - if err != nil { - t.Errorf("Failed to read client cert from file system: %v", err) - errs = append(errs, err) - } - clientCerts, priv := util.DecodePEMBytes(clientCertBytes) - if len(clientCerts) == 0 { - err = errors.New("failed to decode client cert") - t.Error(err) - errs = append(errs, err) - } else { - c.clientCertBytes = pem.EncodeToMemory(clientCerts[0]) - } - if priv == nil { - err = errors.New("failed to decode client key") - t.Error(err) - errs = append(errs, err) - } else { - c.clientKeyBytes = pem.EncodeToMemory(priv) - } - - // Read the CA cert from the file system. - caCertBytes, err := os.ReadFile(pathToCaCert) - if err != nil { - t.Errorf("Failed to read CA cert from file system: %v", err) - errs = append(errs, err) - } - c.caCertBytes = caCertBytes - - return utilerrors.NewAggregate(errs) -} - -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 generateCSR(subject string, dnsNames []string, uris []string, ipAddresses []string) ([]byte, *x509.CertificateRequest, error) { - keyBytes, _ := rsa.GenerateKey(rand.Reader, 2048) - - subj, err := parseSubjectDN(subject, false) - 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, randomizeCn bool) (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": - if randomizeCn { - name.CommonName = fmt.Sprintf("%s-%s", value, generateRandomString(5)) - } else { - name.CommonName = value - } - default: - // Ignore any unknown keys - } - } - - return name, nil -} - -func generateRandomString(length int) string { - mathrand.New(mathrand.NewSource(time.Now().UnixNano())) - letters := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") - b := make([]rune, length) - for i := range b { - b[i] = letters[mathrand.Intn(len(letters))] - } - return string(b) -} diff --git a/pkg/util/configclient.go b/internal/util/configclient.go similarity index 99% rename from pkg/util/configclient.go rename to internal/util/configclient.go index d3dc586..0ee329f 100644 --- a/pkg/util/configclient.go +++ b/internal/util/configclient.go @@ -19,6 +19,7 @@ package util import ( "context" "fmt" + authv1 "k8s.io/api/authorization/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" diff --git a/pkg/util/configclient_test.go b/internal/util/configclient_test.go similarity index 96% rename from pkg/util/configclient_test.go rename to internal/util/configclient_test.go index c96e6a5..2d85ad7 100644 --- a/pkg/util/configclient_test.go +++ b/internal/util/configclient_test.go @@ -18,6 +18,8 @@ package util import ( "context" + "testing" + logrtesting "github.com/go-logr/logr/testr" "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" @@ -26,7 +28,6 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes/fake" ctrl "sigs.k8s.io/controller-runtime" - "testing" ) func TestConfigClient(t *testing.T) { @@ -63,7 +64,7 @@ func TestConfigClient(t *testing.T) { // The fake client doesn't implement authorization.k8s.io/v1 SelfSubjectAccessReview // So we'll mock the verifyAccessFunc - client.verifyAccessFunc = func(apiResource string, resource types.NamespacedName) error { + client.verifyAccessFunc = func(_ string, _ types.NamespacedName) error { return nil } diff --git a/pkg/util/util.go b/internal/util/util.go similarity index 95% rename from pkg/util/util.go rename to internal/util/util.go index 38b6f6e..8d4d176 100644 --- a/pkg/util/util.go +++ b/internal/util/util.go @@ -21,9 +21,10 @@ import ( "encoding/pem" "errors" "fmt" - certificates "k8s.io/api/certificates/v1" "os" "strings" + + certificates "k8s.io/api/certificates/v1" ) const inClusterNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" @@ -92,15 +93,20 @@ func DecodePEMBytes(buf []byte) ([]*pem.Block, *pem.Block) { var privKey *pem.Block var certs []*pem.Block var block *pem.Block + complete := false for { block, buf = pem.Decode(buf) - if block == nil { - break - } else if strings.Contains(block.Type, "PRIVATE KEY") { + switch { + case block == nil: + complete = true + case strings.Contains(block.Type, "PRIVATE KEY"): privKey = block - } else { + default: certs = append(certs, block) } + if complete { + break + } } return certs, privKey } diff --git a/pkg/util/util_test.go b/internal/util/util_test.go similarity index 100% rename from pkg/util/util_test.go rename to internal/util/util_test.go diff --git a/main.go b/main.go index fcd12d8..93c6da7 100644 --- a/main.go +++ b/main.go @@ -19,15 +19,16 @@ import ( "context" "errors" "flag" + "os" + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/controllers" - "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/signer" - "github.com/Keyfactor/ejbca-k8s-csr-signer/pkg/util" + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/ejbca" + "github.com/Keyfactor/ejbca-k8s-csr-signer/internal/util" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/utils/clock" - "os" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -131,13 +132,11 @@ func main() { Name: caCertConfigmapName, } - ejbcaSignerBuilder := signer.NewEjbcaSignerBuilder() - if err = (&controllers.CertificateSigningRequestReconciler{ ConfigClient: configClient, Client: mgr.GetClient(), Scheme: mgr.GetScheme(), - SignerBuilder: ejbcaSignerBuilder, + SignerBuilder: ejbca.NewSigner, ClusterResourceNamespace: clusterResourceNamespace, Clock: clock.RealClock{}, CheckApprovedCondition: !disableApprovedCheck, diff --git a/sample/sample.yaml b/sample/sample.yaml index 313a560..6d3bd8a 100644 --- a/sample/sample.yaml +++ b/sample/sample.yaml @@ -2,15 +2,9 @@ apiVersion: certificates.k8s.io/v1 kind: CertificateSigningRequest metadata: name: ejbcaCsrTest - annotations: - ejbca-k8s-csr-signer.keyfactor.com/certificateProfileName: istioAuth-3d - ejbca-k8s-csr-signer.keyfactor.com/endEntityProfileName: k8sEndEntity - ejbca-k8s-csr-signer.keyfactor.com/certificateAuthorityName: IT-Sub-CA - - estAlias: istio spec: request: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURSBSRVFVRVNULS0tLS0KTUlJQ3VEQ0NBYUFDQVFBd0FEQ0NBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCQU0waApZOTdtam1BT09ic0ZhSUJKMktSdFBFMGxkN3YwT1JIa3dnVCtodFVkd1ZyYzdiZm9zVnJ3UGdGV05oZVl2cTJsCktHdTU2Vk1tWXQ1aXFWOGhFck9rbXhpM1BSK2srSFhqdVJyakpJdnMvYWg2Qm5DZ0ExTmE2U1pRT2hGazUxa0kKaVpuOTQwWFRoaTJ5Q1dLaWlkUEJ0dlNabFE2VDlQdXhOSTJjekVVNDF0QVRUUXZOV0JMMzRXeWJENkpsbHVtWApVb0Foa0x4cElvaU45Ykk4NEgyVWg0bU14ZzUzZEVZMVZmV2EvVWk3czhGUzl1bHNkZmM3MFhvWTJFMHFsbW83ClV6c2NxbklBd0czblNhb3U1anFzbzVoZ2REZFo1NXZhOUdLS0RWUzFGSFJWaUZTMlNWUmRPMTNnWTF1VWc5Y2MKakpwUzc3S2hUbXBHelFjNHRxRUNBd0VBQWFCek1IRUdDU3FHU0liM0RRRUpEakZrTUdJd1lBWURWUjBSQkZrdwpWNElYYVhOMGFXOWtMbWx6ZEdsdkxYTjVjM1JsYlM1emRtT0NIbWx6ZEdsdlpDMXlaVzF2ZEdVdWFYTjBhVzh0CmMzbHpkR1Z0TG5OMlk0SWNhWE4wYVc4dGNHbHNiM1F1YVhOMGFXOHRjM2x6ZEdWdExuTjJZekFOQmdrcWhraUcKOXcwQkFRc0ZBQU9DQVFFQVNvZGZ3dDdJd2FQNVhRVkpSYXF3QUdTTTlwUDZHMnNsMDlqTUtibHhBWTZlSVJ0MAp0OXJ5dkVQOCttYmJFSkJucEZSc3dmL2NIS3pNVDBlNHc2TDNuL3pOYlRTWDdGUWZWNEdqdjIzM3NQS0tvZjloCk5HWXhpOWFHZmR3eitRbFhTS0RUNzJaaE5xUElFdStNNjlXUVRXclhUWjhpeWVmT2JnbzFpdlhxclkxT0UxYTUKWUNJMWVYL0UwRGtMWlkvSVNweUtiUnVTZTVmNUZCZmRuVjNWamM5dGluWmdOTVNLWm1LZHpnS0Z3NVdLY3NPWgpZT2VYS3hueUlxcUhNQS9odWIvYnRrV2ovSExGWlhWb0xCbzIxWnFCKzBQbTRZUFkzMWM2UmoySmtCUjVqWm9qCnMxWm5aNkFoN1Q5V1ZUVE9HVy9YVjhtRStkUS94ZGdxdzgrZnhnPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUgUkVRVUVTVC0tLS0tCg== usages: - client auth - server auth - signerName: "keyfactor.com/kubernetes-integration" \ No newline at end of file + signerName: "keyfactor.com/kubernetes-integration" diff --git a/test/integrationtest.sh b/test/integrationtest.sh new file mode 100755 index 0000000..6e6fb51 --- /dev/null +++ b/test/integrationtest.sh @@ -0,0 +1,163 @@ +#!/bin/bash + +reconciler_namespace="ejbca-signer-system" +reconciler_chart_name="ejbca-k8s-csr-signer" +signer_secret_name="ejbca-credentials" +signer_ca_configmap_name="ejbca-ca-cert" +signer_configmap_name="ejbca-signer-config" +version="latest" + +################# +# Configuration # +################# + +if [ ! "$(kubectl get namespace -o json | jq --arg namespace "$reconciler_namespace" -e '.items[] | select(.metadata.name == $namespace) | .metadata.name')" ]; then + echo "Creating the $reconciler_namespace namespace" + kubectl create ns "$reconciler_namespace" +fi + +if [[ ! $(kubectl get secret -n "$reconciler_namespace" -o json | jq --arg "name" "$signer_secret_name" -e '.items[] | select(.metadata.name == $name)') ]]; then + echo "Creating TLS secret called $signer_secret_name" + kubectl create secret tls "$signer_secret_name" \ + --cert="$EJBCA_CLIENT_CERT_PATH" \ + --key="$EJBCA_CLIENT_CERT_KEY_PATH" \ + -n "$reconciler_namespace" +fi + +if [[ ! $(kubectl get configmap -n "$reconciler_namespace" -o json | jq --arg "name" "$signer_ca_configmap_name" -e '.items[] | select(.metadata.name == $name)') ]]; then + echo "Creating configmap called $signer_ca_configmap_name" + kubectl create configmap "$signer_ca_configmap_name" \ + "--from-file=$EJBCA_CA_CERT_PATH" \ + -n "$reconciler_namespace" +fi + +if [[ ! $(kubectl get configmap -n "$reconciler_namespace" -o json | jq --arg "name" "$signer_configmap_name" -e '.items[] | select(.metadata.name == $name)') ]]; then + echo "Creating secret called $signer_configmap_name" + + kubectl create configmap "$signer_configmap_name" \ + -n "$reconciler_namespace" \ + --from-literal=ejbcaHostname="$EJBCA_IN_CLUSTER_HOSTNAME" \ + --from-literal=defaultEndEntityName="" \ + --from-literal=defaultCertificateProfileName="$EJBCA_CERTIFICATE_PROFILE_NAME" \ + --from-literal=defaultEndEntityProfileName="$EJBCA_END_ENTITY_PROFILE_NAME" \ + --from-literal=defaultCertificateAuthorityName="$EJBCA_CA_NAME" \ + --from-literal=defaultESTAlias="$EJBCA_EST_PROFILE" \ + --from-literal=chainDepth="0" +fi + +echo "Building docker image" +make docker-build DOCKER_REGISTRY=keyfactor DOCKER_IMAGE_NAME="$reconciler_chart_name" VERSION="$version" +kind load docker-image "keyfactor/$reconciler_chart_name:$version" --name chart-testing + +args=( + "$reconciler_chart_name" "deploy/charts/$reconciler_chart_name" + "--namespace" "$reconciler_namespace" + "--create-namespace" + "--set" "image.repository=keyfactor/$reconciler_chart_name" + "--set" "image.tag=$version" + "--set" "image.pullPolicy=Never" + "--set" "metrics.metricsAddress=:8080" + "--set" "ejbca.credsSecretName=$signer_secret_name" + "--set" "ejbca.configMapName=$signer_configmap_name" + "--set" "ejbca.caCertConfigmapName=$signer_ca_configmap_name" +) +if ! helm install "${args[@]}"; then + echo "Failed to deploy Helm chart" + exit 1 +fi + +echo "Waiting for Pod to be ready" +if ! kubectl --namespace "$reconciler_namespace" wait --for=condition=ready pod -l app.kubernetes.io/instance="$reconciler_chart_name" --timeout=30s ; then + echo "Failed to deploy $reconciler_chart_name" + kubectl describe all -A + exit 1 +fi + +if [[ $(kubectl get secret -n "$reconciler_namespace" -o json | jq -r '.items[] | select(.metadata.name == "ejbca-clusterissuer-ca-secret")') == "" ]]; then + echo "Creating secret called ejbca-clusterissuer-ca-secret" + kubectl create secret generic "ejbca-clusterissuer-ca-secret" \ + "--from-file=$EJBCA_CA_CERT_PATH" \ + -n "$reconciler_namespace" +fi + +create_csr() { + local openssl_subject=$1 + local csr_path=$2 + local key_path="$3" + local key_type="$4" + local key_bits=$5 + + if [[ "$key_type" == "ec" ]]; then + openssl req -new -newkey ec:<(openssl ecparam -name "prime$key_bits"v1) -nodes -keyout "$key_path" -out "$csr_path" -subj "$openssl_subject"> /dev/null 2>&1 + else + openssl req -new -newkey "$key_type:$key_bits" -nodes -keyout "$key_path" -out "$csr_path" -subj "$openssl_subject" > /dev/null 2>&1 + fi +} + +################################### +# CertificateSigningRequest Tests # +################################### + +name=$(openssl rand -hex 12) + +create_csr "/CN=example.com" "csr.pem" "priv.key" "rsa" 2048 +csr=$(cat csr.pem | base64 | tr -d '\n' | tr -d '\r') + +kubectl apply -f - <