├── .circleci └── config.yml ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── renovate.json ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── buf.gen.yaml ├── buf.yaml ├── cache.go ├── certify.go ├── certify_suite_test.go ├── certify_test.go ├── cmd └── proxy │ ├── internal │ └── envtypes │ │ ├── issuer.go │ │ ├── keys.go │ │ └── log.go │ └── main.go ├── go.mod ├── go.sum ├── howitworks.svg ├── internal ├── csr │ ├── csr.go │ ├── csr_suite_test.go │ └── csr_test.go ├── keys │ └── keys.go └── tools │ └── tools.go ├── issuer.go ├── issuers ├── aws │ ├── .gitignore │ ├── aws.go │ └── aws_test.go ├── cfssl │ ├── cfssl.go │ ├── cfssl_suite_test.go │ └── cfssl_test.go └── vault │ ├── proto │ ├── test.pb.go │ ├── test.proto │ └── test_grpc.pb.go │ ├── types.go │ ├── vault.go │ ├── vault_suite_test.go │ └── vault_test.go ├── keys.go ├── logger.go ├── logo.png ├── mocks ├── issuer.mock.go ├── logger.mock.go └── net.mock.go ├── request_context.go └── request_context_go_1.17.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: golang:1.19 6 | steps: 7 | - checkout 8 | - run: go build ./... 9 | format: 10 | docker: 11 | - image: golang:1.19 12 | steps: 13 | - checkout 14 | - run: 15 | name: Install gofumports 16 | command: go install mvdan.cc/gofumpt@latest 17 | - run: 18 | name: Run gofumports on all non-generated files 19 | command: grep -L -R "^\/\/ Code generated .* DO NOT EDIT\.$" --exclude-dir=.git --exclude-dir=vendor --include="*.go" | xargs -n 1 gofumpt -w 20 | - run: 21 | name: Check for any changes 22 | command: git diff --exit-code 23 | vendor: 24 | docker: 25 | - image: golang:1.19 26 | steps: 27 | - checkout 28 | - run: go mod verify 29 | - run: go mod tidy 30 | - run: git diff --exit-code 31 | generate: 32 | docker: 33 | - image: golang:1.19 34 | steps: 35 | - checkout 36 | - run: make install 37 | - run: make generate 38 | - run: go mod tidy 39 | - run: git diff --exit-code 40 | test: 41 | machine: 42 | image: ubuntu-2204:2022.04.1 43 | # The CircleCI user's GOPATH 44 | working_directory: /home/circleci/.go_workspace/src/github.com/johanbrandhorst/certify 45 | steps: 46 | - checkout 47 | - run: 48 | name: Update Go installation 49 | command: | 50 | wget https://github.com/lpar/goup/releases/download/1.0/goup-linux-x64.xz && 51 | xz --decompress goup-linux-x64.xz && 52 | chmod +x goup-linux-x64 53 | sudo rm -rf /usr/local/go && 54 | yes | sudo ./goup-linux-x64 --force --os linux --arch amd64 || true # swallow exit 141 55 | - run: go install github.com/onsi/ginkgo/ginkgo 56 | - run: ginkgo -v -r -coverprofile=coverage.txt -cover --randomizeAllSpecs --randomizeSuites --failOnPending --trace --race --progress --skipPackage=vendor --skipMeasurements 57 | - run: bash <(curl -s https://codecov.io/bash) 58 | docker: 59 | docker: 60 | - image: docker 61 | steps: 62 | - setup_remote_docker 63 | - checkout 64 | - run: docker build . 65 | workflows: 66 | version: 2 67 | all: 68 | jobs: 69 | - build 70 | - format 71 | - vendor 72 | - generate 73 | - test 74 | - docker 75 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a problem with Certify 4 | title: '' 5 | labels: bug 6 | assignees: johanbrandhorst 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | 12 | **Steps to reproduce the bug** 13 | 14 | **Expected behavior** 15 | 16 | **What happened instead** 17 | 18 | **Additional context** 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a new feature for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: johanbrandhorst 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "enabled": true, 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "packageRules": [ 7 | { 8 | "matchUpdateTypes": [ 9 | "minor", 10 | "patch", 11 | "pin", 12 | "digest" 13 | ], 14 | "automerge": true 15 | } 16 | ], 17 | "postUpdateOptions": [ 18 | "goModTidy", 19 | "gomodUpdateImportPaths" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor/ 2 | .vscode 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## [v1.9.0](https://github.com/johanbrandhorst/certify/tree/v1.9.0) (2021-08-22) 4 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.8.1...v1.9.0) 5 | 6 | **Implemented enhancements:** 7 | 8 | - Release c3673d8 [\#127](https://github.com/johanbrandhorst/certify/issues/127) 9 | 10 | **Fixed bugs:** 11 | 12 | - improper use of singleflight [\#130](https://github.com/johanbrandhorst/certify/issues/130) 13 | 14 | **Merged pull requests:** 15 | 16 | - Update module github.com/ory/dockertest to v3.7.0 [\#139](https://github.com/johanbrandhorst/certify/pull/139) ([johanbrandhorst](https://github.com/johanbrandhorst)) 17 | - Upgrade all dependencies [\#137](https://github.com/johanbrandhorst/certify/pull/137) ([johanbrandhorst](https://github.com/johanbrandhorst)) 18 | - Configure Renovate [\#136](https://github.com/johanbrandhorst/certify/pull/136) ([renovate[bot]](https://github.com/apps/renovate)) 19 | - Upgrade vault version [\#135](https://github.com/johanbrandhorst/certify/pull/135) ([johanbrandhorst](https://github.com/johanbrandhorst)) 20 | - Upgrade CFSSL version [\#134](https://github.com/johanbrandhorst/certify/pull/134) ([johanbrandhorst](https://github.com/johanbrandhorst)) 21 | - Rewrite AWS issuer [\#133](https://github.com/johanbrandhorst/certify/pull/133) ([johanbrandhorst](https://github.com/johanbrandhorst)) 22 | - Get request context from parameter in Go 1.17 [\#132](https://github.com/johanbrandhorst/certify/pull/132) ([johanbrandhorst](https://github.com/johanbrandhorst)) 23 | - Use certificate name as singleflight key [\#131](https://github.com/johanbrandhorst/certify/pull/131) ([johanbrandhorst](https://github.com/johanbrandhorst)) 24 | 25 | ## [v1.8.1](https://github.com/johanbrandhorst/certify/tree/v1.8.1) (2020-09-09) 26 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.8.0...v1.8.1) 27 | 28 | **Implemented enhancements:** 29 | 30 | - Missing vault issuer documentation [\#121](https://github.com/johanbrandhorst/certify/issues/121) 31 | - Migrate container tests to podrick [\#93](https://github.com/johanbrandhorst/certify/issues/93) 32 | 33 | **Fixed bugs:** 34 | 35 | - Generation CI job is failing [\#125](https://github.com/johanbrandhorst/certify/issues/125) 36 | - Possible nil panic in Vault issuer [\#123](https://github.com/johanbrandhorst/certify/issues/123) 37 | - go 1.15 x509 common name deprecation [\#122](https://github.com/johanbrandhorst/certify/issues/122) 38 | - Vault api go module incorrect [\#116](https://github.com/johanbrandhorst/certify/issues/116) 39 | 40 | **Merged pull requests:** 41 | 42 | - Generate changelog for v1.8.1 [\#128](https://github.com/johanbrandhorst/certify/pull/128) ([johanbrandhorst](https://github.com/johanbrandhorst)) 43 | - Remove moq generation step [\#126](https://github.com/johanbrandhorst/certify/pull/126) ([johanbrandhorst](https://github.com/johanbrandhorst)) 44 | - Fix crash in Vault issuer [\#124](https://github.com/johanbrandhorst/certify/pull/124) ([johanbrandhorst](https://github.com/johanbrandhorst)) 45 | - Add golangpiter talk to README [\#120](https://github.com/johanbrandhorst/certify/pull/120) ([johanbrandhorst](https://github.com/johanbrandhorst)) 46 | - Update dependency versions [\#119](https://github.com/johanbrandhorst/certify/pull/119) ([johanbrandhorst](https://github.com/johanbrandhorst)) 47 | 48 | ## [v1.8.0](https://github.com/johanbrandhorst/certify/tree/v1.8.0) (2020-04-01) 49 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.7.0...v1.8.0) 50 | 51 | **Implemented enhancements:** 52 | 53 | - Support alt\_names in Vault request [\#114](https://github.com/johanbrandhorst/certify/issues/114) 54 | 55 | **Fixed bugs:** 56 | 57 | - Undefined: acmpcaiface.ACMPCAAPI [\#111](https://github.com/johanbrandhorst/certify/issues/111) 58 | 59 | **Merged pull requests:** 60 | 61 | - Generate changelog for 1.8.0 [\#117](https://github.com/johanbrandhorst/certify/pull/117) ([johanbrandhorst](https://github.com/johanbrandhorst)) 62 | - Add SubjectAlternativeNames and IPSubjectAlternativeNames to Vault issuer [\#115](https://github.com/johanbrandhorst/certify/pull/115) ([nvx](https://github.com/nvx)) 63 | 64 | ## [v1.7.0](https://github.com/johanbrandhorst/certify/tree/v1.7.0) (2020-02-28) 65 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.7.0-alpha2...v1.7.0) 66 | 67 | **Fixed bugs:** 68 | 69 | - AWS CM failing [\#108](https://github.com/johanbrandhorst/certify/issues/108) 70 | 71 | **Closed issues:** 72 | 73 | - gRPC with Vault - why does server initiates a CSR with client CN? [\#107](https://github.com/johanbrandhorst/certify/issues/107) 74 | 75 | **Merged pull requests:** 76 | 77 | - Generate changelog for 1.7.0 [\#110](https://github.com/johanbrandhorst/certify/pull/110) ([johanbrandhorst](https://github.com/johanbrandhorst)) 78 | - issuers/aws: Fix incorrectly concatenated certificates [\#109](https://github.com/johanbrandhorst/certify/pull/109) ([johanbrandhorst](https://github.com/johanbrandhorst)) 79 | 80 | ## [v1.7.0-alpha2](https://github.com/johanbrandhorst/certify/tree/v1.7.0-alpha2) (2019-12-29) 81 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.7.0-alpha...v1.7.0-alpha2) 82 | 83 | **Implemented enhancements:** 84 | 85 | - Rotating Vault tokens. [\#101](https://github.com/johanbrandhorst/certify/issues/101) 86 | 87 | **Merged pull requests:** 88 | 89 | - Add Vault AuthMethod to sidecar [\#105](https://github.com/johanbrandhorst/certify/pull/105) ([johanbrandhorst](https://github.com/johanbrandhorst)) 90 | 91 | ## [v1.7.0-alpha](https://github.com/johanbrandhorst/certify/tree/v1.7.0-alpha) (2019-12-26) 92 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.6.0...v1.7.0-alpha) 93 | 94 | **Implemented enhancements:** 95 | 96 | - Ability to reuse private key [\#99](https://github.com/johanbrandhorst/certify/issues/99) 97 | - Update CFSSL to 1.4.0 [\#94](https://github.com/johanbrandhorst/certify/issues/94) 98 | - Publish docker container for certify proxy [\#90](https://github.com/johanbrandhorst/certify/issues/90) 99 | 100 | **Merged pull requests:** 101 | 102 | - Add renewable token auth method to Vault issuer [\#104](https://github.com/johanbrandhorst/certify/pull/104) ([johanbrandhorst](https://github.com/johanbrandhorst)) 103 | - Add AuthMethod to Vault issuer [\#103](https://github.com/johanbrandhorst/certify/pull/103) ([johanbrandhorst](https://github.com/johanbrandhorst)) 104 | - Reuse keys by default [\#100](https://github.com/johanbrandhorst/certify/pull/100) ([johanbrandhorst](https://github.com/johanbrandhorst)) 105 | - Add logger to CFSSL tests [\#98](https://github.com/johanbrandhorst/certify/pull/98) ([johanbrandhorst](https://github.com/johanbrandhorst)) 106 | - Use podrick master instead of branch [\#97](https://github.com/johanbrandhorst/certify/pull/97) ([johanbrandhorst](https://github.com/johanbrandhorst)) 107 | - Migrate CFSSL tests to podrick [\#96](https://github.com/johanbrandhorst/certify/pull/96) ([johanbrandhorst](https://github.com/johanbrandhorst)) 108 | - Update cfssl [\#95](https://github.com/johanbrandhorst/certify/pull/95) ([johanbrandhorst](https://github.com/johanbrandhorst)) 109 | - Speed up fetching by removing branches [\#92](https://github.com/johanbrandhorst/certify/pull/92) ([johanbrandhorst](https://github.com/johanbrandhorst)) 110 | - Remove time param from presentation link [\#87](https://github.com/johanbrandhorst/certify/pull/87) ([jjshanks](https://github.com/jjshanks)) 111 | 112 | ## [v1.6.0](https://github.com/johanbrandhorst/certify/tree/v1.6.0) (2019-10-03) 113 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.5.1...v1.6.0) 114 | 115 | **Merged pull requests:** 116 | 117 | - Update changelog for v1.6.0 [\#86](https://github.com/johanbrandhorst/certify/pull/86) ([johanbrandhorst](https://github.com/johanbrandhorst)) 118 | - Support setting URI SANs in CSRs [\#85](https://github.com/johanbrandhorst/certify/pull/85) ([eandre](https://github.com/eandre)) 119 | 120 | ## [v1.5.1](https://github.com/johanbrandhorst/certify/tree/v1.5.1) (2019-09-26) 121 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.5.0...v1.5.1) 122 | 123 | **Fixed bugs:** 124 | 125 | - Certify fills debug log output with `getting certificate` logs [\#82](https://github.com/johanbrandhorst/certify/issues/82) 126 | 127 | **Merged pull requests:** 128 | 129 | - Update changelog for v1.5.1 [\#84](https://github.com/johanbrandhorst/certify/pull/84) ([johanbrandhorst](https://github.com/johanbrandhorst)) 130 | - Remove excessive logging [\#83](https://github.com/johanbrandhorst/certify/pull/83) ([johanbrandhorst](https://github.com/johanbrandhorst)) 131 | 132 | ## [v1.5.0](https://github.com/johanbrandhorst/certify/tree/v1.5.0) (2019-09-17) 133 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.4.0...v1.5.0) 134 | 135 | **Merged pull requests:** 136 | 137 | - Update changelog for v1.5.0 [\#80](https://github.com/johanbrandhorst/certify/pull/80) ([johanbrandhorst](https://github.com/johanbrandhorst)) 138 | - Add sidecar container [\#79](https://github.com/johanbrandhorst/certify/pull/79) ([johanbrandhorst](https://github.com/johanbrandhorst)) 139 | 140 | ## [v1.4.0](https://github.com/johanbrandhorst/certify/tree/v1.4.0) (2019-07-09) 141 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.3.0...v1.4.0) 142 | 143 | **Implemented enhancements:** 144 | 145 | - Add better tests for OtherSans setting [\#71](https://github.com/johanbrandhorst/certify/issues/71) 146 | - Try to generate moq in module mode again [\#51](https://github.com/johanbrandhorst/certify/issues/51) 147 | - Allow a logger to be configured [\#36](https://github.com/johanbrandhorst/certify/issues/36) 148 | 149 | **Merged pull requests:** 150 | 151 | - main: Update changelog for v1.4.0 [\#76](https://github.com/johanbrandhorst/certify/pull/76) ([johanbrandhorst](https://github.com/johanbrandhorst)) 152 | - Add configurable logger [\#75](https://github.com/johanbrandhorst/certify/pull/75) ([johanbrandhorst](https://github.com/johanbrandhorst)) 153 | - Add noopCache to simplify some logic [\#74](https://github.com/johanbrandhorst/certify/pull/74) ([johanbrandhorst](https://github.com/johanbrandhorst)) 154 | - Properly validate OtherSANs [\#73](https://github.com/johanbrandhorst/certify/pull/73) ([johanbrandhorst](https://github.com/johanbrandhorst)) 155 | 156 | ## [v1.3.0](https://github.com/johanbrandhorst/certify/tree/v1.3.0) (2019-06-18) 157 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.2.0...v1.3.0) 158 | 159 | **Implemented enhancements:** 160 | 161 | - Implement an issuer for whatever Istio uses under the hood to issue certs [\#39](https://github.com/johanbrandhorst/certify/issues/39) 162 | 163 | **Merged pull requests:** 164 | 165 | - main: update changelog for v1.3.0 [\#72](https://github.com/johanbrandhorst/certify/pull/72) ([johanbrandhorst](https://github.com/johanbrandhorst)) 166 | - Add support for URI sans [\#70](https://github.com/johanbrandhorst/certify/pull/70) ([nvx](https://github.com/nvx)) 167 | 168 | ## [v1.2.0](https://github.com/johanbrandhorst/certify/tree/v1.2.0) (2019-04-12) 169 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.1.5...v1.2.0) 170 | 171 | **Implemented enhancements:** 172 | 173 | - Create submodules for issuers [\#67](https://github.com/johanbrandhorst/certify/issues/67) 174 | 175 | **Merged pull requests:** 176 | 177 | - main: update changelog for v1.2.0 [\#69](https://github.com/johanbrandhorst/certify/pull/69) ([johanbrandhorst](https://github.com/johanbrandhorst)) 178 | - Clean up go modules. [\#68](https://github.com/johanbrandhorst/certify/pull/68) ([SpeedyCoder](https://github.com/SpeedyCoder)) 179 | - Add a Gitter chat badge to README.md [\#66](https://github.com/johanbrandhorst/certify/pull/66) ([gitter-badger](https://github.com/gitter-badger)) 180 | 181 | ## [v1.1.5](https://github.com/johanbrandhorst/certify/tree/v1.1.5) (2019-04-05) 182 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.1.4...v1.1.5) 183 | 184 | **Merged pull requests:** 185 | 186 | - main: update changelog for v1.1.5 [\#65](https://github.com/johanbrandhorst/certify/pull/65) ([johanbrandhorst](https://github.com/johanbrandhorst)) 187 | - Fixes missing Leaf in cert [\#64](https://github.com/johanbrandhorst/certify/pull/64) ([jlindsey](https://github.com/jlindsey)) 188 | - main: update changelog for v1.1.4 [\#62](https://github.com/johanbrandhorst/certify/pull/62) ([johanbrandhorst](https://github.com/johanbrandhorst)) 189 | 190 | ## [v1.1.4](https://github.com/johanbrandhorst/certify/tree/v1.1.4) (2019-03-06) 191 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.1.3...v1.1.4) 192 | 193 | **Merged pull requests:** 194 | 195 | - Remove golangci-lint job [\#61](https://github.com/johanbrandhorst/certify/pull/61) ([johanbrandhorst](https://github.com/johanbrandhorst)) 196 | - Update golangci-lint version [\#60](https://github.com/johanbrandhorst/certify/pull/60) ([johanbrandhorst](https://github.com/johanbrandhorst)) 197 | - switch dir cache away from gob to marshalling pem-encoded certs and keys [\#59](https://github.com/johanbrandhorst/certify/pull/59) ([jlindsey](https://github.com/jlindsey)) 198 | - add Users section to README [\#57](https://github.com/johanbrandhorst/certify/pull/57) ([johanbrandhorst](https://github.com/johanbrandhorst)) 199 | - adds public key types to Gob registry [\#56](https://github.com/johanbrandhorst/certify/pull/56) ([jlindsey](https://github.com/jlindsey)) 200 | - Nicer badges! [\#55](https://github.com/johanbrandhorst/certify/pull/55) ([johanbrandhorst](https://github.com/johanbrandhorst)) 201 | - main: update changelog for v1.1.3 [\#53](https://github.com/johanbrandhorst/certify/pull/53) ([johanbrandhorst](https://github.com/johanbrandhorst)) 202 | 203 | ## [v1.1.3](https://github.com/johanbrandhorst/certify/tree/v1.1.3) (2019-03-01) 204 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.1.2...v1.1.3) 205 | 206 | **Merged pull requests:** 207 | 208 | - Fixes Dir cache gob encoding, with tests [\#52](https://github.com/johanbrandhorst/certify/pull/52) ([jlindsey](https://github.com/jlindsey)) 209 | 210 | ## [v1.1.2](https://github.com/johanbrandhorst/certify/tree/v1.1.2) (2019-02-23) 211 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.1.1...v1.1.2) 212 | 213 | **Fixed bugs:** 214 | 215 | - Using dep with certify adds a lot of additional dependencies [\#48](https://github.com/johanbrandhorst/certify/issues/48) 216 | 217 | **Merged pull requests:** 218 | 219 | - main: update changelog for v1.1.2 [\#50](https://github.com/johanbrandhorst/certify/pull/50) ([johanbrandhorst](https://github.com/johanbrandhorst)) 220 | - main: move tools file into subfolder [\#49](https://github.com/johanbrandhorst/certify/pull/49) ([johanbrandhorst](https://github.com/johanbrandhorst)) 221 | - main: update changelog for v1.1.1 [\#47](https://github.com/johanbrandhorst/certify/pull/47) ([johanbrandhorst](https://github.com/johanbrandhorst)) 222 | 223 | ## [v1.1.1](https://github.com/johanbrandhorst/certify/tree/v1.1.1) (2019-02-19) 224 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.1.0...v1.1.1) 225 | 226 | **Merged pull requests:** 227 | 228 | - all: remove vendor folder [\#46](https://github.com/johanbrandhorst/certify/pull/46) ([johanbrandhorst](https://github.com/johanbrandhorst)) 229 | - issuers/vault: simplify alternative mount point tests [\#44](https://github.com/johanbrandhorst/certify/pull/44) ([johanbrandhorst](https://github.com/johanbrandhorst)) 230 | - Add section on how it works to README [\#43](https://github.com/johanbrandhorst/certify/pull/43) ([johanbrandhorst](https://github.com/johanbrandhorst)) 231 | - Add issue templates [\#42](https://github.com/johanbrandhorst/certify/pull/42) ([johanbrandhorst](https://github.com/johanbrandhorst)) 232 | - main: update changelog for v1.1.0 [\#41](https://github.com/johanbrandhorst/certify/pull/41) ([johanbrandhorst](https://github.com/johanbrandhorst)) 233 | 234 | ## [v1.1.0](https://github.com/johanbrandhorst/certify/tree/v1.1.0) (2019-02-16) 235 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v1.0.0...v1.1.0) 236 | 237 | **Merged pull requests:** 238 | 239 | - adds ability to configure mount point name for vault pki [\#40](https://github.com/johanbrandhorst/certify/pull/40) ([jlindsey](https://github.com/jlindsey)) 240 | - Add logo [\#35](https://github.com/johanbrandhorst/certify/pull/35) ([johanbrandhorst](https://github.com/johanbrandhorst)) 241 | 242 | ## [v1.0.0](https://github.com/johanbrandhorst/certify/tree/v1.0.0) (2019-02-03) 243 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v0.3.0...v1.0.0) 244 | 245 | **Closed issues:** 246 | 247 | - Migrate CI to CircleCI for better Github integration and required checks [\#29](https://github.com/johanbrandhorst/certify/issues/29) 248 | - Allow certificate key type to be configured [\#27](https://github.com/johanbrandhorst/certify/issues/27) 249 | - Re-enable generate CI check [\#26](https://github.com/johanbrandhorst/certify/issues/26) 250 | - Migrate to Go modules [\#24](https://github.com/johanbrandhorst/certify/issues/24) 251 | 252 | **Merged pull requests:** 253 | 254 | - main: Add CHANGELOG.md [\#34](https://github.com/johanbrandhorst/certify/pull/34) ([johanbrandhorst](https://github.com/johanbrandhorst)) 255 | - Allow key configuration [\#33](https://github.com/johanbrandhorst/certify/pull/33) ([johanbrandhorst](https://github.com/johanbrandhorst)) 256 | - main: update README.md with CircleCI badge [\#31](https://github.com/johanbrandhorst/certify/pull/31) ([johanbrandhorst](https://github.com/johanbrandhorst)) 257 | - Migrate CI to CircleCI [\#30](https://github.com/johanbrandhorst/certify/pull/30) ([johanbrandhorst](https://github.com/johanbrandhorst)) 258 | - Unexport Connect, add CFFSL test for unencrypted connections [\#28](https://github.com/johanbrandhorst/certify/pull/28) ([johanbrandhorst](https://github.com/johanbrandhorst)) 259 | - Migrate from dep to go modules [\#25](https://github.com/johanbrandhorst/certify/pull/25) ([johanbrandhorst](https://github.com/johanbrandhorst)) 260 | 261 | ## [v0.3.0](https://github.com/johanbrandhorst/certify/tree/v0.3.0) (2019-01-26) 262 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v0.2.0...v0.3.0) 263 | 264 | **Closed issues:** 265 | 266 | - Add support for AWS CA [\#16](https://github.com/johanbrandhorst/certify/issues/16) 267 | - Make it possible to create issuers from Vault/CFSSL clients. [\#11](https://github.com/johanbrandhorst/certify/issues/11) 268 | 269 | **Merged pull requests:** 270 | 271 | - Minor fixes [\#23](https://github.com/johanbrandhorst/certify/pull/23) ([johanbrandhorst](https://github.com/johanbrandhorst)) 272 | - Add AWS CA issuer [\#22](https://github.com/johanbrandhorst/certify/pull/22) ([johanbrandhorst](https://github.com/johanbrandhorst)) 273 | - Allow issuers to be created from API clients [\#21](https://github.com/johanbrandhorst/certify/pull/21) ([johanbrandhorst](https://github.com/johanbrandhorst)) 274 | 275 | ## [v0.2.0](https://github.com/johanbrandhorst/certify/tree/v0.2.0) (2019-01-26) 276 | [Full Changelog](https://github.com/johanbrandhorst/certify/compare/v0.1.0...v0.2.0) 277 | 278 | **Closed issues:** 279 | 280 | - Switch Vault Issuer to CSR signer [\#12](https://github.com/johanbrandhorst/certify/issues/12) 281 | 282 | **Merged pull requests:** 283 | 284 | - Deduplicate simultaneous issue requests, small improvements [\#20](https://github.com/johanbrandhorst/certify/pull/20) ([johanbrandhorst](https://github.com/johanbrandhorst)) 285 | - issuers/vault: Switch to CSR signing [\#19](https://github.com/johanbrandhorst/certify/pull/19) ([johanbrandhorst](https://github.com/johanbrandhorst)) 286 | 287 | ## [v0.1.0](https://github.com/johanbrandhorst/certify/tree/v0.1.0) (2018-09-26) 288 | **Closed issues:** 289 | 290 | - Add better explanation to CFSSL struct components [\#14](https://github.com/johanbrandhorst/certify/issues/14) 291 | - Implement Cloudflare CA issuer backend [\#5](https://github.com/johanbrandhorst/certify/issues/5) 292 | 293 | **Merged pull requests:** 294 | 295 | - vault: fix ca\_chain conversion [\#18](https://github.com/johanbrandhorst/certify/pull/18) ([johanbrandhorst](https://github.com/johanbrandhorst)) 296 | - Whoops missing comma. [\#17](https://github.com/johanbrandhorst/certify/pull/17) ([bweston92](https://github.com/bweston92)) 297 | - Better cfssl docs [\#15](https://github.com/johanbrandhorst/certify/pull/15) ([johanbrandhorst](https://github.com/johanbrandhorst)) 298 | - Add cfssl issuer [\#13](https://github.com/johanbrandhorst/certify/pull/13) ([johanbrandhorst](https://github.com/johanbrandhorst)) 299 | - Made the repo public, maybe now it'll work? [\#10](https://github.com/johanbrandhorst/certify/pull/10) ([johanbrandhorst](https://github.com/johanbrandhorst)) 300 | - Add directory Cache implementation [\#9](https://github.com/johanbrandhorst/certify/pull/9) ([johanbrandhorst](https://github.com/johanbrandhorst)) 301 | - Remove pipeline status badge :\(. [\#8](https://github.com/johanbrandhorst/certify/pull/8) ([johanbrandhorst](https://github.com/johanbrandhorst)) 302 | - Add pipeline status badge [\#7](https://github.com/johanbrandhorst/certify/pull/7) ([johanbrandhorst](https://github.com/johanbrandhorst)) 303 | - Update README.md [\#6](https://github.com/johanbrandhorst/certify/pull/6) ([lukasmalkmus](https://github.com/lukasmalkmus)) 304 | - Rename repo and package to Certify [\#4](https://github.com/johanbrandhorst/certify/pull/4) ([johanbrandhorst](https://github.com/johanbrandhorst)) 305 | - Add initial CI configuration [\#3](https://github.com/johanbrandhorst/certify/pull/3) ([johanbrandhorst](https://github.com/johanbrandhorst)) 306 | - Remove multiple urls [\#2](https://github.com/johanbrandhorst/certify/pull/2) ([johanbrandhorst](https://github.com/johanbrandhorst)) 307 | - Initial checkin [\#1](https://github.com/johanbrandhorst/certify/pull/1) ([johanbrandhorst](https://github.com/johanbrandhorst)) 308 | 309 | 310 | 311 | \* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 as build-env 2 | 3 | COPY . /src/certify 4 | 5 | ENV CGO_ENABLED=0 6 | 7 | RUN cd /src/certify && go build -o /proxy ./cmd/proxy/main.go 8 | 9 | FROM gcr.io/distroless/static 10 | 11 | COPY --from=build-env /proxy /proxy 12 | 13 | ENTRYPOINT ["/proxy"] 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Johan Brandhorst 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: generate 2 | generate: 3 | buf generate 4 | 5 | .PHONY: install 6 | install: 7 | go install github.com/bufbuild/buf/cmd/buf@v1.1.1 8 | go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28.0 9 | go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1.0 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Certify 2 | 3 | [![CircleCI](https://img.shields.io/circleci/project/github/johanbrandhorst/certify/master.svg?style=flat-square)](https://circleci.com/gh/johanbrandhorst/certify) 4 | [![GoDoc](http://img.shields.io/badge/godoc-reference-5272B4.svg?style=flat-square)](https://godoc.org/github.com/johanbrandhorst/certify) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/johanbrandhorst/certify?style=flat-square)](https://goreportcard.com/report/github.com/johanbrandhorst/certify) 6 | [![Code Coverage](https://img.shields.io/codecov/c/github/johanbrandhorst/certify/master.svg?style=flat-square)](https://codecov.io/gh/johanbrandhorst/certify) 7 | [![Releases](https://img.shields.io/github/release/johanbrandhorst/certify.svg?style=flat-square)](https://github.com/johanbrandhorst/certify/releases) 8 | [![License](https://img.shields.io/github/license/johanbrandhorst/certify.svg?style=flat-square)](LICENSE) 9 | [![Join the chat at https://gitter.im/go-certify/community](https://img.shields.io/gitter/room/go-certify/community.svg?style=flat-square)](https://gitter.im/go-certify/community) 10 | 11 | ![Certify](logo.png "Certify") 12 | 13 | Certify allows easy automatic certificate distribution and maintenance. 14 | Certificates are requested as TLS connections 15 | are made, courtesy of the `GetCertificate` and `GetClientCertificate` 16 | `tls.Config` hooks. Certificates are optionally cached. Simultaneous requests 17 | are deduplicated to minimize pressure on issuers. 18 | 19 | ## Vault walkthrough 20 | 21 | My presentation from [GolangPiter 2019](https://golangpiter.com/en/materials/2646) 22 | contains a walkthrough of how to configure your Vault instance to securely issue 23 | certificates for your Go clients and servers. 24 | 25 | [![Certify presentation](https://img.youtube.com/vi/boG7BSRaJ9E/0.jpg)](https://www.youtube.com/watch?v=boG7BSRaJ9E) 26 | 27 | ## Users 28 | 29 | - [Georgetown University](https://www.georgetown.edu/) 30 | 31 | Are you using Certify and want to be visible here? Open an issue! 32 | 33 | ## Issuers 34 | 35 | Certify exposes an `Issuer` interface which is used to allow switching 36 | between issuer backends. 37 | 38 | Currently implemented issuers: 39 | 40 | - [Vault PKI Secrets Engine](https://vaultproject.io) 41 | - [Cloudflare CFSSL Certificate Authority](https://cfssl.org/) 42 | - [AWS Certificate Manager Private Certificate Authority](https://aws.amazon.com/certificate-manager/private-certificate-authority/) 43 | 44 | ## Usage 45 | 46 | Create an issuer: 47 | 48 | ```go 49 | issuer := &vault.Issuer{ 50 | URL: &url.URL{ 51 | Scheme: "https", 52 | Host: "my-local-vault-instance.com", 53 | }, 54 | Token: "myVaultToken", 55 | Role: "myVaultRole", 56 | } 57 | ``` 58 | 59 | Create a Certify: 60 | 61 | ```go 62 | c := &certify.Certify{ 63 | // Used when request client-side certificates and 64 | // added to SANs or IPSANs depending on format. 65 | CommonName: "MyServer.com", 66 | Issuer: issuer, 67 | // It is recommended to use a cache. 68 | Cache: certify.NewMemCache(), 69 | // It is recommended to set RenewBefore. 70 | // Refresh cached certificates when < 24H left before expiry. 71 | RenewBefore: 24*time.Hour, 72 | } 73 | ``` 74 | 75 | Use in your TLS Config: 76 | 77 | ```go 78 | tlsConfig := &tls.Config{ 79 | GetCertificate: c.GetCertificate, 80 | } 81 | ``` 82 | 83 | That's it! Both server-side and client-side certificates 84 | can be generated: 85 | 86 | ```go 87 | tlsConfig := &tls.Config{ 88 | GetClientCertificate: c.GetClientCertificate, 89 | } 90 | ``` 91 | 92 | For an end-to-end example using gRPC with mutual TLS authentication, 93 | see the [Vault tests](./issuers/vault/vault_test.go). 94 | 95 | ### Vault PKI Key Types 96 | 97 | When setting up a Vault PKI backend and creating a role for Certify to use when it requests certificates, you'll be asked to specify the key type for the role to use. By default, Certify uses `ecdsa` keys with a 256-bit key length when it generates CSRs for Vault to sign. 98 | 99 | If your Vault PKI role is created with a key type other than `ec` or `any`, API calls to Vault will fail with errors like 100 | 101 | ```bash 102 | Error making API request. 103 | 104 | URL: PUT https://localhost:8200/v1/pki/sign/example.com 105 | Code: 400. Errors: 106 | 107 | * role requires keys of type rsa 108 | ``` 109 | 110 | To use Certify with `rsa` or `ed25519` keys, you'll need to pass a custom `KeyGenerator` to Certify which satisfies the `certify.KeyGenerator` [interface](https://github.com/johanbrandhorst/certify/blob/168d95c011b19e999a92014956c0d537ab6ff2fc/issuer.go#L17-L20). For example, for an `rsa` key: 111 | 112 | ```go 113 | 114 | type rsaKeyGenerator struct { 115 | key crypto.PrivateKey 116 | err error 117 | o sync.Once 118 | } 119 | 120 | // This satisfies the `certify.KeyGenerator` interface. 121 | func (s *rsaKeyGenerator) Generate() (crypto.PrivateKey, error) { 122 | s.o.Do(func() { 123 | // Use a different random data provider and key length if required. 124 | s.key, s.err = rsa.GenerateKey(rand.Reader, 2048) 125 | }) 126 | return s.key, s.err 127 | } 128 | 129 | // Configure Certify's CSR generator to use our custom KeyGenerator 130 | cfg := &certify.CertConfig{ 131 | KeyGenerator: &rsaKeyGenerator{}, 132 | } 133 | 134 | certify := &certify.Certify{ 135 | CommonName: "service1.example.com", 136 | Cache: certify.DirCache("certificates"), 137 | Issuer: issuer, 138 | RenewBefore: 10 * time.Minute, 139 | // Pass our custom configuration to Certify 140 | CertConfig: cfg, 141 | } 142 | ``` 143 | 144 | ## Docker image (sidecar model) 145 | 146 | If you really want to use Certify but you are not able to use Go, there is 147 | now a [Docker image](https://hub.docker.com/r/jfbrandhorst/certify) available! 148 | 149 | Simply configure this image as the access point for your Kubernetes pod and 150 | let it proxy traffic to your server. 151 | 152 | ## How does it work? 153 | 154 | ![How it works](howitworks.svg "How it works") 155 | 156 | Certify hooks into the `GetCertificate` and `GetClientCertificate` methods of 157 | the Go TLS stack `Config` struct. These get called when the server/client 158 | respectively is required to present its certificate. If possible, this is 159 | fetched from the cache, based on the requested server name. If not, a new 160 | certificate is issued with the requested server name present. For client 161 | requests, the configured `CommonName` is used. 162 | -------------------------------------------------------------------------------- /buf.gen.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | plugins: 3 | - name: go 4 | out: . 5 | opt: 6 | - paths=source_relative 7 | - name: go-grpc 8 | out: . 9 | opt: 10 | - paths=source_relative 11 | - require_unimplemented_servers=false 12 | -------------------------------------------------------------------------------- /buf.yaml: -------------------------------------------------------------------------------- 1 | version: v1 2 | breaking: 3 | use: 4 | - FILE 5 | lint: 6 | use: 7 | - DEFAULT 8 | -------------------------------------------------------------------------------- /cache.go: -------------------------------------------------------------------------------- 1 | package certify 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "errors" 9 | "fmt" 10 | "io/ioutil" 11 | "os" 12 | "path/filepath" 13 | "sync" 14 | 15 | "github.com/johanbrandhorst/certify/internal/keys" 16 | ) 17 | 18 | const ( 19 | keyExt = ".key" 20 | certExt = ".crt" 21 | ) 22 | 23 | // Cache describes the interface that certificate caches must implement. 24 | // Cache implementations must be thread safe. 25 | type Cache interface { 26 | // Get returns a certificate data for the specified key. 27 | // If there's no such key, Get returns ErrCacheMiss. 28 | Get(context.Context, string) (*tls.Certificate, error) 29 | 30 | // Put stores the data in the cache under the specified key. 31 | Put(context.Context, string, *tls.Certificate) error 32 | 33 | // Delete removes a certificate data from the cache under the specified key. 34 | // If there's no such key in the cache, Delete returns nil. 35 | Delete(context.Context, string) error 36 | } 37 | 38 | // ErrCacheMiss should be returned by Cache implementations 39 | // when a certificate could not be found. 40 | var ErrCacheMiss = errors.New("no matching certificate found") 41 | 42 | type memCache struct { 43 | mu *sync.RWMutex 44 | cache map[string]*tls.Certificate 45 | } 46 | 47 | // NewMemCache creates an in-memory cache that implements the Cache interface. 48 | func NewMemCache() Cache { 49 | return &memCache{ 50 | mu: &sync.RWMutex{}, 51 | cache: map[string]*tls.Certificate{}, 52 | } 53 | } 54 | 55 | func (m memCache) Get(_ context.Context, key string) (*tls.Certificate, error) { 56 | m.mu.RLock() 57 | defer m.mu.RUnlock() 58 | 59 | cert, ok := m.cache[key] 60 | if ok { 61 | return cert, nil 62 | } 63 | 64 | return nil, ErrCacheMiss 65 | } 66 | 67 | func (m *memCache) Put(_ context.Context, key string, cert *tls.Certificate) error { 68 | m.mu.Lock() 69 | defer m.mu.Unlock() 70 | 71 | m.cache[key] = cert 72 | return nil 73 | } 74 | 75 | func (m *memCache) Delete(_ context.Context, key string) error { 76 | m.mu.Lock() 77 | defer m.mu.Unlock() 78 | 79 | delete(m.cache, key) 80 | return nil 81 | } 82 | 83 | // DirCache implements Cache using a directory on the local filesystem. 84 | // If the directory does not exist, it will be created with 0700 permissions. 85 | // 86 | // It is strongly based on the acme/autocert DirCache type. 87 | // https://github.com/golang/crypto/blob/88942b9c40a4c9d203b82b3731787b672d6e809b/acme/autocert/cache.go#L40 88 | type DirCache string 89 | 90 | // Get reads a certificate data from the specified file name. 91 | func (d DirCache) Get(ctx context.Context, name string) (*tls.Certificate, error) { 92 | name = filepath.Join(string(d), name) 93 | 94 | var ( 95 | cert tls.Certificate 96 | err error 97 | done = make(chan struct{}) 98 | ) 99 | 100 | go func() { 101 | cert, err = tls.LoadX509KeyPair(name+certExt, name+keyExt) 102 | if err == nil { 103 | // Need to parse the Leaf manually for expiration checks 104 | var leaf *x509.Certificate 105 | leaf, err = x509.ParseCertificate(cert.Certificate[0]) 106 | if err == nil { 107 | cert.Leaf = leaf 108 | } 109 | } 110 | close(done) 111 | }() 112 | 113 | select { 114 | case <-ctx.Done(): 115 | return nil, ctx.Err() 116 | case <-done: 117 | } 118 | 119 | if os.IsNotExist(err) { 120 | return nil, ErrCacheMiss 121 | } 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | return &cert, nil 127 | } 128 | 129 | // Put writes the certificate data to the specified file name. 130 | // The file will be created with 0600 permissions. 131 | func (d DirCache) Put(ctx context.Context, name string, cert *tls.Certificate) error { 132 | if err := os.MkdirAll(string(d), 0o700); err != nil { 133 | return err 134 | } 135 | 136 | done := make(chan struct{}) 137 | var ( 138 | err error 139 | tmpKey, tmpCert string 140 | newName = filepath.Join(string(d), name) 141 | ) 142 | go func() { 143 | defer close(done) 144 | 145 | var tmpKey, tmpCert string 146 | if tmpKey, tmpCert, err = d.writeTempFiles(name, cert); err != nil { 147 | return 148 | } 149 | 150 | select { 151 | case <-ctx.Done(): 152 | // Don't overwrite the file if the context was canceled. 153 | default: 154 | newName := filepath.Join(string(d), name) 155 | err = os.Rename(tmpKey, newName+keyExt) 156 | if err != nil { 157 | return 158 | } 159 | err = os.Rename(tmpCert, newName+certExt) 160 | if err != nil { 161 | return 162 | } 163 | } 164 | }() 165 | 166 | select { 167 | case <-ctx.Done(): 168 | return ctx.Err() 169 | case <-done: 170 | } 171 | 172 | // Clean up after ourselves on error, remove all artifacts from this request 173 | if err != nil { 174 | err = removeWrapErr(tmpKey, err) 175 | err = removeWrapErr(tmpCert, err) 176 | err = removeWrapErr(newName+keyExt, err) 177 | err = removeWrapErr(newName+certExt, err) 178 | } 179 | 180 | return err 181 | } 182 | 183 | // Delete removes the specified file name. 184 | func (d DirCache) Delete(ctx context.Context, name string) error { 185 | name = filepath.Join(string(d), name) 186 | var ( 187 | err error 188 | done = make(chan struct{}) 189 | ) 190 | go func() { 191 | defer close(done) 192 | 193 | err = removeWrapErr(name+keyExt, err) 194 | err = removeWrapErr(name+certExt, err) 195 | }() 196 | select { 197 | case <-ctx.Done(): 198 | return ctx.Err() 199 | case <-done: 200 | } 201 | 202 | return err 203 | } 204 | 205 | func removeWrapErr(fileName string, err error) error { 206 | if e := os.Remove(fileName); e != nil && !os.IsNotExist(e) { 207 | err = fmt.Errorf("failed to delete %s: %v: %v", fileName, e, err) 208 | } 209 | 210 | return err 211 | } 212 | 213 | // writeTempFile writes b to a temporary file, closes the file and returns its path. 214 | func (d DirCache) writeTempFiles(prefix string, cert *tls.Certificate) (string, string, error) { 215 | keyPath, err := d.writeTempKey(prefix, cert) 216 | if err != nil { 217 | return "", "", err 218 | } 219 | 220 | certPath, err := d.writeTempCert(prefix, cert) 221 | if err != nil { 222 | return "", "", err 223 | } 224 | 225 | return keyPath, certPath, err 226 | } 227 | 228 | func (d DirCache) writeTempKey(prefix string, cert *tls.Certificate) (string, error) { 229 | pem, err := keys.Marshal(cert.PrivateKey) 230 | if err != nil { 231 | return "", err 232 | } 233 | 234 | // TempFile uses 0600 permissions 235 | f, err := ioutil.TempFile(string(d), prefix+keyExt) 236 | if err != nil { 237 | return "", err 238 | } 239 | 240 | if _, err = f.Write(pem); err != nil { 241 | return "", err 242 | } 243 | 244 | return f.Name(), f.Close() 245 | } 246 | 247 | func (d DirCache) writeTempCert(prefix string, cert *tls.Certificate) (string, error) { 248 | f, err := ioutil.TempFile(string(d), prefix+certExt) 249 | if err != nil { 250 | return "", err 251 | } 252 | 253 | for _, c := range cert.Certificate { 254 | block := &pem.Block{ 255 | Type: "CERTIFICATE", 256 | Bytes: c, 257 | } 258 | 259 | err := pem.Encode(f, block) 260 | if err != nil { 261 | return "", err 262 | } 263 | } 264 | 265 | return f.Name(), f.Close() 266 | } 267 | 268 | type noopCache struct{} 269 | 270 | func (*noopCache) Get(context.Context, string) (*tls.Certificate, error) { 271 | return nil, ErrCacheMiss 272 | } 273 | 274 | func (*noopCache) Put(context.Context, string, *tls.Certificate) error { 275 | return nil 276 | } 277 | 278 | func (*noopCache) Delete(context.Context, string) error { 279 | return nil 280 | } 281 | -------------------------------------------------------------------------------- /certify.go: -------------------------------------------------------------------------------- 1 | package certify 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "errors" 7 | "strings" 8 | "sync" 9 | "time" 10 | 11 | "golang.org/x/sync/singleflight" 12 | ) 13 | 14 | // Certify implements automatic certificate acquisition 15 | // via the configured Issuer. 16 | // 17 | // CommonName and Issuer are required. 18 | // It is recommended that you specify a Cache to prevent requesting a 19 | // new certificate for every incoming connection. 20 | type Certify struct { 21 | // CommonName is the Certificate Common Name 22 | // that will be used when issuing certificates. 23 | // This can be a DNS record or a regular name. 24 | CommonName string 25 | 26 | // Issuer is the certificate issuer to use. 27 | Issuer Issuer 28 | 29 | // RenewBefore configures how long before 30 | // expiry a certificate should be considered too 31 | // old to use when fetched from the cache. 32 | RenewBefore time.Duration 33 | 34 | // Cache is the Cache implementation to use. 35 | Cache Cache 36 | 37 | // CertConfig is the certificate configuration that 38 | // should be used. It can be specified to set explicit 39 | // requirements of certificates issued. 40 | CertConfig *CertConfig 41 | 42 | // IssueTimeout is the upper bound of time allowed 43 | // per certificate call. Defaults to 1 minute. 44 | IssueTimeout time.Duration 45 | 46 | // Logger configures logging of events such as renewals. 47 | // Defaults to no logging. Use one of the adapters in 48 | // https://logur.dev/logur to use with specific 49 | // logging libraries, or implement the interface yourself. 50 | Logger Logger 51 | 52 | issueGroup singleflight.Group 53 | initOnce sync.Once 54 | } 55 | 56 | func (c *Certify) init() { 57 | if c.Cache == nil { 58 | c.Cache = &noopCache{} 59 | } 60 | if c.Logger == nil { 61 | c.Logger = &noopLogger{} 62 | } 63 | if c.IssueTimeout == 0 { 64 | c.IssueTimeout = time.Minute 65 | } 66 | if c.CertConfig == nil { 67 | c.CertConfig = &CertConfig{} 68 | } 69 | if c.CertConfig.KeyGenerator == nil { 70 | c.CertConfig.KeyGenerator = &singletonKey{} 71 | } 72 | } 73 | 74 | // GetCertificate implements the GetCertificate TLS config hook. 75 | func (c *Certify) GetCertificate(hello *tls.ClientHelloInfo) (cert *tls.Certificate, err error) { 76 | c.initOnce.Do(c.init) 77 | defer func() { 78 | if err != nil { 79 | c.Logger.Error("Error getting server certificate", map[string]interface{}{ 80 | "error": err.Error(), 81 | }) 82 | return 83 | } 84 | }() 85 | 86 | name := strings.ToLower(hello.ServerName) 87 | if name == "" { 88 | return nil, errors.New("missing server name") 89 | } 90 | if strings.ContainsAny(name, `/\`) { 91 | return nil, errors.New("server name contains invalid character") 92 | } 93 | 94 | // Remove ending dot, if any 95 | name = strings.TrimSuffix(name, ".") 96 | 97 | // Remove port, if used 98 | if strings.Contains(name, ":") { 99 | name = strings.Split(name, ":")[0] 100 | } 101 | 102 | ctx := getRequestContext(hello) 103 | return c.getOrRenewCert(ctx, name) 104 | } 105 | 106 | // GetClientCertificate implements the GetClientCertificate TLS config hook. 107 | func (c *Certify) GetClientCertificate(cri *tls.CertificateRequestInfo) (cert *tls.Certificate, err error) { 108 | c.initOnce.Do(c.init) 109 | defer func() { 110 | if err != nil { 111 | c.Logger.Error("Error getting client certificate", map[string]interface{}{ 112 | "error": err.Error(), 113 | }) 114 | return 115 | } 116 | }() 117 | ctx := getClientRequestContext(cri) 118 | return c.getOrRenewCert(ctx, c.CommonName) 119 | } 120 | 121 | func (c *Certify) getOrRenewCert(ctx context.Context, name string) (*tls.Certificate, error) { 122 | ctx, cancel := context.WithTimeout(ctx, c.IssueTimeout) 123 | defer cancel() 124 | 125 | cert, err := c.Cache.Get(ctx, name) 126 | if err == nil { 127 | // If we're not within the renewal threshold of the expiry, return the cert 128 | if time.Now().Before(cert.Leaf.NotAfter.Add(-c.RenewBefore)) { 129 | return cert, nil 130 | } 131 | c.Logger.Debug("Cached certificate found but expiry within renewal threshold", map[string]interface{}{ 132 | "serial": cert.Leaf.SerialNumber.String(), 133 | "expiry": cert.Leaf.NotAfter.Format(time.RFC3339), 134 | }) 135 | // Delete the cert, we want to renew it 136 | _ = c.Cache.Delete(ctx, name) 137 | } else if err != ErrCacheMiss { 138 | return nil, err 139 | } 140 | 141 | // De-duplicate simultaneous requests for the same name 142 | ch := c.issueGroup.DoChan(name, func() (interface{}, error) { 143 | c.Logger.Debug("Requesting new certificate from issuer") 144 | conf := c.CertConfig.Clone() 145 | conf.appendName(name) 146 | 147 | // Add CommonName to SANS if not already added 148 | if name != c.CommonName { 149 | conf.appendName(c.CommonName) 150 | } 151 | 152 | cert, err := c.Issuer.Issue(ctx, c.CommonName, conf) 153 | if err != nil { 154 | return nil, err 155 | } 156 | 157 | c.Logger.Debug("New certificate issued", map[string]interface{}{ 158 | "serial": cert.Leaf.SerialNumber.String(), 159 | "expiry": cert.Leaf.NotAfter.Format(time.RFC3339), 160 | }) 161 | 162 | err = c.Cache.Put(ctx, name, cert) 163 | if err != nil { 164 | c.Logger.Error("Failed to save certificate in cache", map[string]interface{}{ 165 | "error": err.Error(), 166 | }) 167 | // Ignore error, it'll just mean we renew again next time 168 | } 169 | 170 | return cert, nil 171 | }) 172 | 173 | select { 174 | case <-ctx.Done(): 175 | return nil, ctx.Err() 176 | case res := <-ch: 177 | if res.Err != nil { 178 | return nil, res.Err 179 | } 180 | return res.Val.(*tls.Certificate), nil 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /certify_suite_test.go: -------------------------------------------------------------------------------- 1 | package certify_test 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/tls" 9 | "crypto/x509" 10 | "crypto/x509/pkix" 11 | "encoding/pem" 12 | "fmt" 13 | "math/big" 14 | "net" 15 | "testing" 16 | "time" 17 | 18 | . "github.com/onsi/ginkgo" 19 | . "github.com/onsi/gomega" 20 | ) 21 | 22 | func TestCertify(t *testing.T) { 23 | RegisterFailHandler(Fail) 24 | RunSpecs(t, "Certify Suite") 25 | } 26 | 27 | func generateCertAndKey(SAN string, IPSAN net.IP, keyFunc keyGeneratorFunc) (*tls.Certificate, error) { 28 | priv, err := keyFunc.Generate() 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | notBefore := time.Now() 34 | notAfter := notBefore.Add(time.Hour) 35 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 36 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 37 | if err != nil { 38 | return nil, err 39 | } 40 | template := x509.Certificate{ 41 | SerialNumber: serialNumber, 42 | Subject: pkix.Name{ 43 | CommonName: "Certify Test Cert", 44 | }, 45 | NotBefore: notBefore, 46 | NotAfter: notAfter, 47 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 48 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 49 | BasicConstraintsValid: true, 50 | DNSNames: []string{SAN}, 51 | IPAddresses: []net.IP{IPSAN}, 52 | } 53 | 54 | var ( 55 | pubKey crypto.PublicKey 56 | encodedKey []byte 57 | ) 58 | switch p := priv.(type) { 59 | case *rsa.PrivateKey: 60 | pubKey = p.Public() 61 | encodedKey = x509.MarshalPKCS1PrivateKey(p) 62 | case *ecdsa.PrivateKey: 63 | pubKey = p.Public() 64 | encoded, err := x509.MarshalECPrivateKey(p) 65 | if err != nil { 66 | return nil, err 67 | } 68 | encodedKey = encoded 69 | default: 70 | return nil, fmt.Errorf("Unsupported key type") 71 | } 72 | 73 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, pubKey, priv) 74 | if err != nil { 75 | return nil, err 76 | } 77 | certOut := pem.EncodeToMemory(&pem.Block{ 78 | Type: "CERTIFICATE", 79 | Bytes: derBytes, 80 | }) 81 | keyOut := pem.EncodeToMemory(&pem.Block{ 82 | Type: "RSA PRIVATE KEY", 83 | Bytes: encodedKey, 84 | }) 85 | 86 | cert, err := tls.X509KeyPair(certOut, keyOut) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | cert.Leaf, err = x509.ParseCertificate(cert.Certificate[0]) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | return &cert, nil 97 | } 98 | -------------------------------------------------------------------------------- /certify_test.go: -------------------------------------------------------------------------------- 1 | package certify_test 2 | 3 | import ( 4 | "context" 5 | "crypto" 6 | "crypto/ecdsa" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/rsa" 10 | "crypto/tls" 11 | "crypto/x509" 12 | "errors" 13 | "fmt" 14 | "io/ioutil" 15 | "math/big" 16 | "net" 17 | "net/url" 18 | "sync" 19 | "time" 20 | 21 | . "github.com/onsi/ginkgo" 22 | . "github.com/onsi/gomega" 23 | . "github.com/onsi/gomega/gstruct" 24 | 25 | "github.com/johanbrandhorst/certify" 26 | "github.com/johanbrandhorst/certify/mocks" 27 | ) 28 | 29 | var _ = Describe("Caches", func() { 30 | // Note: this setup step doesn't clean 31 | // up this directory properly after running. 32 | mustMakeTempDir := func() string { 33 | n, err := ioutil.TempDir("", "") 34 | if err != nil { 35 | panic(err) 36 | } 37 | return n 38 | } 39 | 40 | caches := []struct { 41 | Type string 42 | Cache certify.Cache 43 | }{ 44 | {Type: "MemCache", Cache: certify.NewMemCache()}, 45 | {Type: "DirCache", Cache: certify.DirCache(mustMakeTempDir())}, 46 | } 47 | 48 | keyFuncs := map[string]keyGeneratorFunc{ 49 | "rsa": func() (crypto.PrivateKey, error) { return rsa.GenerateKey(rand.Reader, 2048) }, 50 | "ecdsa": func() (crypto.PrivateKey, error) { return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) }, 51 | } 52 | 53 | for _, cache := range caches { 54 | c := cache 55 | Context("when using "+c.Type, func() { 56 | Context("after putting in a certificate", func() { 57 | It("allows a user to get and delete it", func() { 58 | for keyName, genFunc := range keyFuncs { 59 | By("generating a " + keyName + " key") 60 | cert, err := generateCertAndKey("localhost", net.IPv4(127, 0, 0, 1), genFunc) 61 | Expect(err).To(Succeed()) 62 | Expect(c.Cache.Put(context.Background(), "key1", cert)).To(Succeed()) 63 | cached, err := c.Cache.Get(context.Background(), "key1") 64 | Expect(err).To(Succeed()) 65 | Expect(cached.Leaf).To(Not(BeNil())) 66 | Expect(cached).To(BeEquivalentTo(cert)) 67 | Expect(c.Cache.Delete(context.Background(), "key1")).To(Succeed()) 68 | _, err = c.Cache.Get(context.Background(), "key1") 69 | Expect(err).To(Equal(certify.ErrCacheMiss)) 70 | } 71 | }) 72 | }) 73 | 74 | Context("when getting a key that doesn't exist", func() { 75 | It("returns ErrCacheMiss", func() { 76 | _, err := c.Cache.Get(context.Background(), "key1") 77 | Expect(err).To(Equal(certify.ErrCacheMiss)) 78 | }) 79 | }) 80 | 81 | Context("when deleting a key that doesn't exist", func() { 82 | It("does not return an error", func() { 83 | Expect(c.Cache.Delete(context.Background(), "key1")).To(Succeed()) 84 | }) 85 | }) 86 | 87 | Context("when accessing the cache concurrently", func() { 88 | It("does not cause any race conditions", func() { 89 | start := make(chan struct{}) 90 | wg := sync.WaitGroup{} 91 | key := "key1" 92 | 93 | cert, err := generateCertAndKey("localhost", net.IPv4(127, 0, 0, 1), keyFuncs["rsa"]) 94 | Expect(err).To(Succeed()) 95 | 96 | for i := 0; i < 3; i++ { 97 | wg.Add(1) 98 | go func() { 99 | defer wg.Done() 100 | defer GinkgoRecover() 101 | 102 | Eventually(start).Should(BeClosed()) 103 | Expect(c.Cache.Put(context.Background(), key, cert)).To(Succeed()) 104 | Expect(c.Cache.Get(context.Background(), key)).NotTo(BeNil()) 105 | }() 106 | } 107 | 108 | // Synchronize goroutines 109 | close(start) 110 | wg.Wait() 111 | 112 | Expect(c.Cache.Delete(context.Background(), key)).To(Succeed()) 113 | }) 114 | }) 115 | }) 116 | } 117 | }) 118 | 119 | var _ = Describe("Certify", func() { 120 | It("issues a valid certificate", func() { 121 | serverName := "myotherserver.com" 122 | issuer := &mocks.IssuerMock{} 123 | logger := &mocks.LoggerMock{ 124 | ErrorFunc: func(msg string, args ...map[string]interface{}) { 125 | fmt.Fprintf(GinkgoWriter, msg+": %v\n", args) 126 | }, 127 | DebugFunc: func(msg string, args ...map[string]interface{}) { 128 | fmt.Fprintf(GinkgoWriter, msg+": %v\n", args) 129 | }, 130 | } 131 | cli := &certify.Certify{ 132 | CommonName: "myserver.com", 133 | Issuer: issuer, 134 | CertConfig: &certify.CertConfig{ 135 | SubjectAlternativeNames: []string{"extraname.com"}, 136 | IPSubjectAlternativeNames: []net.IP{net.IPv4(1, 2, 3, 4)}, 137 | URISubjectAlternativeNames: []*url.URL{{Scheme: "https", Host: "example.org"}}, 138 | }, 139 | Logger: logger, 140 | } 141 | issuer.IssueFunc = func(in1 context.Context, in2 string, in3 *certify.CertConfig) (*tls.Certificate, error) { 142 | defer GinkgoRecover() 143 | Expect(in2).To(Equal(cli.CommonName)) 144 | switch len(issuer.IssueCalls()) { 145 | case 1: 146 | // First call is GetCertificate 147 | Expect(in3).To(PointTo(MatchAllFields(Fields{ 148 | "SubjectAlternativeNames": Equal(append(cli.CertConfig.SubjectAlternativeNames, serverName, cli.CommonName)), 149 | "IPSubjectAlternativeNames": Equal(cli.CertConfig.IPSubjectAlternativeNames), 150 | "URISubjectAlternativeNames": Equal(cli.CertConfig.URISubjectAlternativeNames), 151 | "KeyGenerator": Not(BeNil()), 152 | }))) 153 | case 2: 154 | // Second call is GetClientCertificate 155 | Expect(in3).To(PointTo(MatchAllFields(Fields{ 156 | "SubjectAlternativeNames": Equal(append(cli.CertConfig.SubjectAlternativeNames, cli.CommonName)), 157 | "IPSubjectAlternativeNames": Equal(cli.CertConfig.IPSubjectAlternativeNames), 158 | "URISubjectAlternativeNames": Equal(cli.CertConfig.URISubjectAlternativeNames), 159 | "KeyGenerator": Not(BeNil()), 160 | }))) 161 | } 162 | pk, err := in3.KeyGenerator.Generate() 163 | Expect(err).To(Succeed()) 164 | Expect(pk).To(BeAssignableToTypeOf(&ecdsa.PrivateKey{})) 165 | Expect(pk.(*ecdsa.PrivateKey).Params().BitSize).To(BeEquivalentTo(256)) 166 | return &tls.Certificate{ 167 | Leaf: &x509.Certificate{ 168 | SerialNumber: big.NewInt(123456), 169 | NotAfter: time.Now().Add(time.Hour), 170 | }, 171 | }, nil 172 | } 173 | 174 | _, err := cli.GetCertificate(&tls.ClientHelloInfo{ 175 | ServerName: serverName, 176 | Conn: &mocks.ConnMock{ 177 | Remote: mocks.AddrMock{ 178 | S: "127.0.0.1", 179 | }, 180 | }, 181 | }) 182 | Expect(err).To(Succeed()) 183 | _, err = cli.GetClientCertificate(&tls.CertificateRequestInfo{}) 184 | Expect(err).To(Succeed()) 185 | 186 | Expect(issuer.IssueCalls()).To(HaveLen(2)) 187 | }) 188 | 189 | Context("and there is a matching certificate in the cache", func() { 190 | It("doesn't request a new one from Vault", func() { 191 | issuer := &mocks.IssuerMock{} 192 | cli := &certify.Certify{ 193 | CommonName: "myserver.com", 194 | Issuer: issuer, 195 | Cache: certify.NewMemCache(), 196 | } 197 | 198 | issuer.IssueFunc = func(in1 context.Context, in2 string, in3 *certify.CertConfig) (*tls.Certificate, error) { 199 | defer GinkgoRecover() 200 | Expect(in2).To(Equal(cli.CommonName)) 201 | Expect(in3).To(PointTo(MatchAllFields(Fields{ 202 | "SubjectAlternativeNames": Equal([]string{cli.CommonName}), 203 | "IPSubjectAlternativeNames": BeEmpty(), 204 | "URISubjectAlternativeNames": BeEmpty(), 205 | "KeyGenerator": Not(BeNil()), 206 | }))) 207 | return &tls.Certificate{ 208 | Leaf: &x509.Certificate{ 209 | SerialNumber: big.NewInt(123456), 210 | NotAfter: time.Now().Add(time.Minute), 211 | }, 212 | }, nil 213 | } 214 | 215 | _, err := cli.GetCertificate(&tls.ClientHelloInfo{ 216 | ServerName: cli.CommonName, 217 | Conn: &mocks.ConnMock{ 218 | Remote: mocks.AddrMock{ 219 | S: "127.0.0.1", 220 | }, 221 | }, 222 | }) 223 | Expect(err).To(Succeed()) 224 | 225 | _, err = cli.GetClientCertificate(&tls.CertificateRequestInfo{}) 226 | Expect(err).To(Succeed()) 227 | 228 | // Should only have called once 229 | Expect(issuer.IssueCalls()).To(HaveLen(1)) 230 | }) 231 | 232 | Context("but the certificate expiry is within the RenewBefore", func() { 233 | It("requests a new certificate", func() { 234 | issuer := &mocks.IssuerMock{} 235 | cli := &certify.Certify{ 236 | CommonName: "myserver.com", 237 | Issuer: issuer, 238 | Cache: certify.NewMemCache(), 239 | RenewBefore: time.Hour, 240 | } 241 | issuer.IssueFunc = func(in1 context.Context, in2 string, in3 *certify.CertConfig) (*tls.Certificate, error) { 242 | defer GinkgoRecover() 243 | Expect(in2).To(Equal(cli.CommonName)) 244 | Expect(in3).To(PointTo(MatchAllFields(Fields{ 245 | "SubjectAlternativeNames": Equal([]string{cli.CommonName}), 246 | "IPSubjectAlternativeNames": BeEmpty(), 247 | "URISubjectAlternativeNames": BeEmpty(), 248 | "KeyGenerator": Not(BeNil()), 249 | }))) 250 | return &tls.Certificate{ 251 | Leaf: &x509.Certificate{ 252 | SerialNumber: big.NewInt(123456), 253 | NotAfter: time.Now().Add(time.Minute), 254 | }, 255 | }, nil 256 | } 257 | 258 | _, err := cli.GetCertificate(&tls.ClientHelloInfo{ 259 | ServerName: cli.CommonName, 260 | Conn: &mocks.ConnMock{ 261 | Remote: mocks.AddrMock{ 262 | S: "127.0.0.1", 263 | }, 264 | }, 265 | }) 266 | Expect(err).To(Succeed()) 267 | 268 | _, err = cli.GetClientCertificate(&tls.CertificateRequestInfo{}) 269 | Expect(err).To(Succeed()) 270 | 271 | Expect(issuer.IssueCalls()).To(HaveLen(2)) 272 | }) 273 | }) 274 | }) 275 | 276 | Context("when the server name can be parsed as an IP", func() { 277 | It("populates the IPSubjectAlternativeNames", func() { 278 | serverName := "8.8.8.8" 279 | issuer := &mocks.IssuerMock{} 280 | cli := &certify.Certify{ 281 | CommonName: "myserver.com", 282 | Issuer: issuer, 283 | } 284 | issuer.IssueFunc = func(in1 context.Context, in2 string, in3 *certify.CertConfig) (*tls.Certificate, error) { 285 | defer GinkgoRecover() 286 | Expect(in2).To(Equal(cli.CommonName)) 287 | Expect(in3).To(PointTo(MatchAllFields(Fields{ 288 | "SubjectAlternativeNames": Equal([]string{cli.CommonName}), 289 | "IPSubjectAlternativeNames": Equal([]net.IP{net.ParseIP(serverName)}), 290 | "URISubjectAlternativeNames": BeEmpty(), 291 | "KeyGenerator": Not(BeNil()), 292 | }))) 293 | return &tls.Certificate{ 294 | Leaf: &x509.Certificate{ 295 | SerialNumber: big.NewInt(123456), 296 | NotAfter: time.Now().Add(time.Hour), 297 | }, 298 | }, nil 299 | } 300 | 301 | _, err := cli.GetCertificate(&tls.ClientHelloInfo{ 302 | ServerName: serverName, 303 | Conn: &mocks.ConnMock{ 304 | Remote: mocks.AddrMock{ 305 | S: "127.0.0.1", 306 | }, 307 | }, 308 | }) 309 | Expect(err).To(Succeed()) 310 | Expect(issuer.IssueCalls()).To(HaveLen(1)) 311 | }) 312 | }) 313 | 314 | Context("when a custom keyGenerator is used", func() { 315 | It("passes it to the Issue call", func() { 316 | serverName := "8.8.8.8" 317 | issuer := &mocks.IssuerMock{} 318 | kgf := keyGeneratorFunc(func() (crypto.PrivateKey, error) { 319 | return nil, errors.New("test error") 320 | }) 321 | cli := &certify.Certify{ 322 | CommonName: "myserver.com", 323 | Issuer: issuer, 324 | CertConfig: &certify.CertConfig{ 325 | KeyGenerator: kgf, 326 | }, 327 | } 328 | issuer.IssueFunc = func(in1 context.Context, in2 string, in3 *certify.CertConfig) (*tls.Certificate, error) { 329 | defer GinkgoRecover() 330 | Expect(in2).To(Equal(cli.CommonName)) 331 | Expect(in3).To(PointTo(MatchAllFields(Fields{ 332 | "SubjectAlternativeNames": Equal([]string{cli.CommonName}), 333 | "IPSubjectAlternativeNames": Equal([]net.IP{net.ParseIP(serverName)}), 334 | "URISubjectAlternativeNames": BeEmpty(), 335 | "KeyGenerator": Not(BeNil()), 336 | }))) 337 | _, err := in3.KeyGenerator.Generate() 338 | Expect(err).To(MatchError("test error")) 339 | return &tls.Certificate{ 340 | Leaf: &x509.Certificate{ 341 | SerialNumber: big.NewInt(123456), 342 | NotAfter: time.Now().Add(time.Hour), 343 | }, 344 | }, nil 345 | } 346 | 347 | _, err := cli.GetCertificate(&tls.ClientHelloInfo{ 348 | ServerName: serverName, 349 | Conn: &mocks.ConnMock{ 350 | Remote: mocks.AddrMock{ 351 | S: "127.0.0.1", 352 | }, 353 | }, 354 | }) 355 | Expect(err).To(Succeed()) 356 | Expect(issuer.IssueCalls()).To(HaveLen(1)) 357 | }) 358 | }) 359 | 360 | Context("when several requests are made at the same time", func() { 361 | It("only calls to the issuer once", func() { 362 | issuer := &mocks.IssuerMock{} 363 | cli := &certify.Certify{ 364 | CommonName: "myserver.com", 365 | Issuer: issuer, 366 | } 367 | wait := make(chan struct{}) 368 | issuer.IssueFunc = func(in1 context.Context, in2 string, in3 *certify.CertConfig) (*tls.Certificate, error) { 369 | defer GinkgoRecover() 370 | Expect(in2).To(Equal(cli.CommonName)) 371 | Expect(in3).To(PointTo(MatchAllFields(Fields{ 372 | "SubjectAlternativeNames": Equal([]string{cli.CommonName}), 373 | "IPSubjectAlternativeNames": BeEmpty(), 374 | "URISubjectAlternativeNames": BeEmpty(), 375 | "KeyGenerator": Not(BeNil()), 376 | }))) 377 | <-wait 378 | return &tls.Certificate{ 379 | Leaf: &x509.Certificate{ 380 | SerialNumber: big.NewInt(100), 381 | NotAfter: time.Now().Add(time.Hour), 382 | }, 383 | }, nil 384 | } 385 | 386 | gr1 := make(chan struct{}) 387 | go func() { 388 | defer GinkgoRecover() 389 | cert, err := cli.GetClientCertificate(&tls.CertificateRequestInfo{}) 390 | Expect(err).To(Succeed()) 391 | Expect(cert.Leaf.SerialNumber.Int64()).To(BeEquivalentTo(100)) 392 | close(gr1) 393 | }() 394 | 395 | gr2 := make(chan struct{}) 396 | go func() { 397 | defer GinkgoRecover() 398 | cert, err := cli.GetClientCertificate(&tls.CertificateRequestInfo{}) 399 | Expect(err).To(Succeed()) 400 | Expect(cert.Leaf.SerialNumber.Int64()).To(BeEquivalentTo(100)) 401 | close(gr2) 402 | }() 403 | 404 | // Let the goroutines start 405 | time.Sleep(10 * time.Millisecond) 406 | 407 | close(wait) 408 | Eventually(gr1).Should(BeClosed()) 409 | Eventually(gr2).Should(BeClosed()) 410 | 411 | Expect(issuer.IssueCalls()).To(HaveLen(1)) 412 | }) 413 | }) 414 | }) 415 | 416 | type keyGeneratorFunc func() (crypto.PrivateKey, error) 417 | 418 | func (kgf keyGeneratorFunc) Generate() (crypto.PrivateKey, error) { 419 | return kgf() 420 | } 421 | -------------------------------------------------------------------------------- /cmd/proxy/internal/envtypes/issuer.go: -------------------------------------------------------------------------------- 1 | package envtypes 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "strings" 7 | "time" 8 | 9 | "github.com/johanbrandhorst/certify/issuers/vault" 10 | ) 11 | 12 | // Issuer is an enumeration of supported issuers 13 | type Issuer int 14 | 15 | // Supported issuers 16 | const ( 17 | VaultIssuer = iota 18 | CFSSLIssuer 19 | AWSIssuer 20 | ) 21 | 22 | // UnmarshalText implements encoding.TextUnmarshaler for issuer. 23 | func (i *Issuer) UnmarshalText(in []byte) error { 24 | switch strings.ToLower(string(in)) { 25 | case "vault", "hashicorp": 26 | *i = VaultIssuer 27 | case "cfssl", "cloudflare": 28 | *i = CFSSLIssuer 29 | case "aws", "amazon", "acmpca", "awscmpca": 30 | *i = AWSIssuer 31 | default: 32 | return errors.New(`invalid issuer specified, supported issuers are "vault", "cfssl" and "aws"`) 33 | } 34 | return nil 35 | } 36 | 37 | // AuthMethod is an enumeration of supported auth methods 38 | type AuthMethod int 39 | 40 | // Supported auth methods 41 | const ( 42 | UnknownAuthMethod = iota 43 | ConstantTokenAuthMethod 44 | RenewingTokenAuthMethod 45 | ) 46 | 47 | // UnmarshalText implements encoding.TextUnmarshaler for AuthMethod. 48 | func (am *AuthMethod) UnmarshalText(in []byte) error { 49 | switch strings.ToLower(string(in)) { 50 | case "constant", "token", "constant_token": 51 | *am = ConstantTokenAuthMethod 52 | case "renewing", "renewing_token": 53 | *am = RenewingTokenAuthMethod 54 | default: 55 | *am = UnknownAuthMethod 56 | } 57 | return nil 58 | } 59 | 60 | // Vault issuer configuration. 61 | type Vault struct { 62 | URL url.URL `desc:"The URL of the Vault instance."` 63 | Token string `desc:"The Vault secret token that should be used when issuing certificates. DEPRECATED; use AuthMethod instead."` 64 | AuthMethod AuthMethod `split_words:"true" desc:"The method to use for authenticating against Vault. Supported methods are constant and renewing."` 65 | AuthMethodRenewingToken struct { 66 | Initial string `desc:"The token used to initially authenticate against Vault. It must be renewable."` 67 | RenewBefore time.Duration `split_words:"true" default:"30m" desc:"How long before the expiry of the token it should be renewed."` 68 | TimeToLive time.Duration `split_words:"true" default:"24h" desc:"How long the new token should be valid for."` 69 | } `split_words:"true" desc:"Configuration of the renewing token."` 70 | AuthMethodConstantToken vault.ConstantToken `split_words:"true" desc:"The constant token to use when talking to Vault."` 71 | Mount string `default:"pki" desc:"The name under which the PKI secrets engine is mounted."` 72 | Role string `desc:"The Vault Role that should be used when issuing certificates."` 73 | CACertPath string `envconfig:"CA_CERT_PATH" desc:"The path to the CA cert to use when connecting to Vault. If not set, will use publically trusted CAs."` 74 | TimeToLive time.Duration `split_words:"true" default:"720h" desc:"Configures the lifetime of certificates requested from the Vault server."` 75 | URISubjectAlternativeNames []string `envconfig:"URI_SUBJECT_ALTERNATIVE_NAMES" desc:"Custom URI SANs that should be used in issued certificates. The format is a URI and must match the value specified in allowed_uri_sans, eg spiffe://hostname/foobar."` 76 | OtherSubjectAlternativeNames []string `envconfig:"OTHER_SUBJECT_ALTERNATIVE_NAMES" desc:"Custom OID/UTF8-string SANs that should be used in issued certificates. The format is the same as OpenSSL: ;: where the only current valid is UTF8."` 77 | } 78 | 79 | // CFSSL issuer configuration. 80 | type CFSSL struct { 81 | URL url.URL `desc:"The URL of the CFSSL server."` 82 | CACertPath string `envconfig:"CA_CERT_PATH" desc:"The path to the CA cert to use when connecting to Vault. If not set, will use publically trusted CAs."` 83 | Profile string `desc:"The profile on the CFSSL server that should be used. If unset, the default profile will be used."` 84 | AuthKey string `split_words:"true" desc:"Optionally defines an authentication key to use when connecting to CFSSL."` 85 | } 86 | 87 | // AWS issuer configuration. 88 | type AWS struct { 89 | Region string `desc:"The AWS region to use."` 90 | AccessKeyID string `envconfig:"ACCESS_KEY_ID" desc:"The AWS access key ID to use for authenticating with AWS."` 91 | AccessKeySecret string `split_words:"true" desc:"The AWS access key secret to use for authenticating with AWS."` 92 | CertificateAuthorityARN string `envconfig:"CERTIFICATE_AUTHORITY_ARN" desc:"The ARN of a pre-created CA which will be used to issue the certificates."` 93 | TimeToLive int `default:"30" desc:"The lifetime of certificates requested from the AWS CA, in number of days."` 94 | } 95 | -------------------------------------------------------------------------------- /cmd/proxy/internal/envtypes/keys.go: -------------------------------------------------------------------------------- 1 | package envtypes 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "crypto/rsa" 9 | "errors" 10 | "strings" 11 | ) 12 | 13 | // KeyGenerator defines the key generator to use 14 | type KeyGenerator func() (crypto.PrivateKey, error) 15 | 16 | // UnmarshalText implements encoding.TextUnmarshaler for KeyGenerator 17 | func (k *KeyGenerator) UnmarshalText(in []byte) error { 18 | switch strings.ToLower(string(in)) { 19 | case "ec", "ecdsa": 20 | *k = func() (crypto.PrivateKey, error) { 21 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 22 | } 23 | case "rsa": 24 | *k = func() (crypto.PrivateKey, error) { 25 | return rsa.GenerateKey(rand.Reader, 2048) 26 | } 27 | default: 28 | return errors.New(`invalid key generator specified, supported key generators are "ecdsa" and "rsa"`) 29 | } 30 | return nil 31 | } 32 | 33 | // Generate implements certify.KeyGenerator for KeyGenerator 34 | func (k KeyGenerator) Generate() (crypto.PrivateKey, error) { 35 | return k() 36 | } 37 | -------------------------------------------------------------------------------- /cmd/proxy/internal/envtypes/log.go: -------------------------------------------------------------------------------- 1 | package envtypes 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | "github.com/sirupsen/logrus" 8 | ) 9 | 10 | // LogFormat is used to format logs 11 | type LogFormat func(*logrus.Entry) ([]byte, error) 12 | 13 | // UnmarshalText implements encoding.TextUnmarshaler for LogFormat 14 | func (l *LogFormat) UnmarshalText(in []byte) error { 15 | switch strings.ToLower(string(in)) { 16 | case "json": 17 | *l = (&logrus.JSONFormatter{}).Format 18 | case "text": 19 | *l = (&logrus.TextFormatter{}).Format 20 | default: 21 | return errors.New(`invalid log format specified, supported formats are "json" and "text"`) 22 | } 23 | return nil 24 | } 25 | 26 | // Format implements logrus.Formatter for LogFormat 27 | func (l LogFormat) Format(in *logrus.Entry) ([]byte, error) { 28 | return l(in) 29 | } 30 | -------------------------------------------------------------------------------- /cmd/proxy/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "fmt" 9 | "io/ioutil" 10 | "net" 11 | "net/http" 12 | "net/http/httputil" 13 | "net/url" 14 | "os" 15 | "os/signal" 16 | "syscall" 17 | "time" 18 | 19 | awssdk "github.com/aws/aws-sdk-go-v2/aws" 20 | "github.com/aws/aws-sdk-go-v2/service/acmpca" 21 | "github.com/cloudflare/cfssl/auth" 22 | "github.com/kelseyhightower/envconfig" 23 | "github.com/sirupsen/logrus" 24 | logrusadapter "logur.dev/adapter/logrus" 25 | 26 | "github.com/johanbrandhorst/certify" 27 | "github.com/johanbrandhorst/certify/cmd/proxy/internal/envtypes" 28 | "github.com/johanbrandhorst/certify/issuers/aws" 29 | "github.com/johanbrandhorst/certify/issuers/cfssl" 30 | "github.com/johanbrandhorst/certify/issuers/vault" 31 | ) 32 | 33 | type configuration struct { 34 | Addr string `default:":443" desc:"The address to serve the proxy on."` 35 | ProxyAddr string `default:"localhost:80" split_words:"true" desc:"The host to proxy to, without scheme."` 36 | LogFormat envtypes.LogFormat `default:"json" split_words:"true" desc:"Log formatter to use. One of json or text."` 37 | 38 | Issuer envtypes.Issuer `required:"true" desc:"The certificate issuer to use. One of vault, cfssl or aws."` 39 | Vault envtypes.Vault 40 | CFSSL envtypes.CFSSL 41 | AWS envtypes.AWS 42 | 43 | CacheDir string `split_words:"true" desc:"Directory to cache certs. If unset, will cache in memory."` 44 | CommonName string `required:"true" split_words:"true" desc:"The Common Name that will be used when issuing certificates. This can be a DNS record or a regular name."` 45 | RenewBefore time.Duration `split_words:"true" default:"4h" desc:"How long before expiry a certificate should be considered too old to use when fetched from the cache."` 46 | IssueTimeout time.Duration `default:"1m" split_words:"true" desc:"The upper bound of time allowed per Issue request."` 47 | SubjectAlternativeNames []string `split_words:"true" desc:"A comma-separated list of DNS names that should be included in the SANs of the issued certificates."` 48 | IPSubjectAlternativeNames []net.IP `envconfig:"IP_SUBJECT_ALTERNATIVE_NAMES" desc:"A comma-separated list of IPs that should be included in the IPSANs of the issued certificates."` 49 | KeyGenerator envtypes.KeyGenerator `split_words:"true" default:"ecdsa" desc:"The key algorithm to use for new certificates. One of ecdsa and rsa."` 50 | } 51 | 52 | func main() { 53 | conf := configuration{} 54 | err := envconfig.Process("", &conf) 55 | if err != nil { 56 | _ = envconfig.Usage("", &conf) 57 | fmt.Println(err) 58 | os.Exit(1) 59 | } 60 | 61 | log := logrus.New() 62 | log.Formatter = conf.LogFormat 63 | 64 | var issuer certify.Issuer 65 | switch conf.Issuer { 66 | case envtypes.VaultIssuer: 67 | issuer, err = vaultIssuer(conf.Vault) 68 | if err != nil { 69 | log.WithError(err).Fatal("Failed to configure Vault issuer.") 70 | } 71 | case envtypes.CFSSLIssuer: 72 | issuer, err = cfsslIssuer(conf.CFSSL) 73 | if err != nil { 74 | log.WithError(err).Fatal("Failed to configure CFSSL issuer.") 75 | } 76 | case envtypes.AWSIssuer: 77 | issuer, err = awsIssuer(conf.AWS) 78 | if err != nil { 79 | log.WithError(err).Fatal("Failed to configure AWS issuer.") 80 | } 81 | } 82 | 83 | cache := certify.NewMemCache() 84 | if conf.CacheDir != "" { 85 | cache = certify.DirCache(conf.CacheDir) 86 | } 87 | 88 | c := certify.Certify{ 89 | Issuer: issuer, 90 | Cache: cache, 91 | Logger: logrusadapter.New(log), 92 | 93 | CommonName: conf.CommonName, 94 | RenewBefore: conf.RenewBefore, 95 | IssueTimeout: conf.IssueTimeout, 96 | CertConfig: &certify.CertConfig{ 97 | SubjectAlternativeNames: conf.SubjectAlternativeNames, 98 | IPSubjectAlternativeNames: conf.IPSubjectAlternativeNames, 99 | }, 100 | } 101 | 102 | u := &url.URL{ 103 | Scheme: "http", 104 | Host: conf.ProxyAddr, 105 | } 106 | 107 | s := &http.Server{ 108 | Addr: conf.Addr, 109 | Handler: httputil.NewSingleHostReverseProxy(u), 110 | TLSConfig: &tls.Config{ 111 | GetCertificate: c.GetCertificate, 112 | GetClientCertificate: c.GetClientCertificate, 113 | }, 114 | } 115 | 116 | notifyShutdown(log, s) 117 | 118 | log.Infof("Proxying traffic from %q to %q", conf.Addr, conf.ProxyAddr) 119 | 120 | err = s.ListenAndServeTLS("", "") 121 | if err != http.ErrServerClosed { 122 | log.WithError(err).Error("Failed to serve") 123 | } 124 | } 125 | 126 | func notifyShutdown(log *logrus.Logger, s *http.Server) { 127 | cls := make(chan os.Signal, 1) 128 | signal.Notify(cls, syscall.SIGINT, syscall.SIGTERM) 129 | go func() { 130 | <-cls 131 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) 132 | defer cancel() 133 | err := s.Shutdown(ctx) 134 | if err != nil { 135 | log.WithError(err).Error("Failed to gracefully shut down server") 136 | } 137 | }() 138 | } 139 | 140 | func vaultIssuer(conf envtypes.Vault) (*vault.Issuer, error) { 141 | if conf.URL.String() == "" { 142 | return nil, errors.New("vault URL is required") 143 | } 144 | if conf.Token == "" && conf.AuthMethod == envtypes.UnknownAuthMethod { 145 | return nil, errors.New("vault Token or AuthMethod is required") 146 | } 147 | if conf.Role == "" { 148 | return nil, errors.New("vault Role is required") 149 | } 150 | v := &vault.Issuer{ 151 | URL: &conf.URL, 152 | Role: conf.Role, 153 | Mount: conf.Mount, 154 | TimeToLive: conf.TimeToLive, 155 | URISubjectAlternativeNames: conf.URISubjectAlternativeNames, 156 | OtherSubjectAlternativeNames: conf.OtherSubjectAlternativeNames, 157 | TLSConfig: &tls.Config{}, 158 | } 159 | switch conf.AuthMethod { 160 | case envtypes.ConstantTokenAuthMethod: 161 | if conf.AuthMethodConstantToken == "" { 162 | return nil, errors.New("vault constant token is required when using the constant auth method") 163 | } 164 | v.AuthMethod = conf.AuthMethodConstantToken 165 | case envtypes.RenewingTokenAuthMethod: 166 | if conf.AuthMethodRenewingToken.Initial == "" { 167 | return nil, errors.New("vault initial renewing token is required when using the renewing auth method") 168 | } 169 | v.AuthMethod = &vault.RenewingToken{ 170 | Initial: conf.AuthMethodRenewingToken.Initial, 171 | RenewBefore: conf.AuthMethodRenewingToken.RenewBefore, 172 | TimeToLive: conf.AuthMethodRenewingToken.TimeToLive, 173 | } 174 | default: 175 | v.AuthMethod = vault.ConstantToken(conf.Token) 176 | } 177 | if conf.CACertPath != "" { 178 | v.TLSConfig.RootCAs = x509.NewCertPool() 179 | bts, err := ioutil.ReadFile(conf.CACertPath) 180 | if err != nil { 181 | return nil, fmt.Errorf("failed to read CA cert file: %w", err) 182 | } 183 | v.TLSConfig.RootCAs.AppendCertsFromPEM(bts) 184 | } 185 | 186 | return v, nil 187 | } 188 | 189 | func cfsslIssuer(conf envtypes.CFSSL) (*cfssl.Issuer, error) { 190 | if conf.URL.String() == "" { 191 | return nil, errors.New("CFSSL URL is required") 192 | } 193 | c := &cfssl.Issuer{ 194 | URL: &conf.URL, 195 | Profile: conf.Profile, 196 | TLSConfig: &tls.Config{}, 197 | } 198 | if conf.CACertPath != "" { 199 | c.TLSConfig.RootCAs = x509.NewCertPool() 200 | bts, err := ioutil.ReadFile(conf.CACertPath) 201 | if err != nil { 202 | return nil, fmt.Errorf("failed to read CA cert file: %w", err) 203 | } 204 | c.TLSConfig.RootCAs.AppendCertsFromPEM(bts) 205 | } 206 | if conf.AuthKey != "" { 207 | st, err := auth.New(conf.AuthKey, nil) 208 | if err != nil { 209 | return nil, fmt.Errorf("failed to create CFSSL auth: %w", err) 210 | } 211 | c.Auth = st 212 | } 213 | 214 | return c, nil 215 | } 216 | 217 | func awsIssuer(conf envtypes.AWS) (*aws.Issuer, error) { 218 | if conf.Region == "" { 219 | return nil, errors.New("AWS region is required") 220 | } 221 | if conf.AccessKeyID == "" { 222 | return nil, errors.New("AWS access key ID is required") 223 | } 224 | if conf.AccessKeySecret == "" { 225 | return nil, errors.New("AWS access key secret is required") 226 | } 227 | if conf.CertificateAuthorityARN == "" { 228 | return nil, errors.New("AWS CA ARN is required") 229 | } 230 | ac := awssdk.Config{} 231 | ac.Region = conf.Region 232 | ac.Credentials = awssdk.CredentialsProviderFunc(func(c context.Context) (awssdk.Credentials, error) { 233 | return awssdk.Credentials{ 234 | AccessKeyID: conf.AccessKeyID, 235 | SecretAccessKey: conf.AccessKeySecret, 236 | }, nil 237 | }) 238 | return &aws.Issuer{ 239 | Client: acmpca.NewFromConfig(ac), 240 | CertificateAuthorityARN: conf.CertificateAuthorityARN, 241 | TimeToLive: conf.TimeToLive, 242 | }, nil 243 | } 244 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/johanbrandhorst/certify 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2 v1.16.16 7 | github.com/aws/aws-sdk-go-v2/service/acmpca v1.18.1 8 | github.com/cloudflare/cfssl v1.6.3 9 | github.com/golang/protobuf v1.5.2 10 | github.com/hashicorp/vault/api v1.8.0 11 | github.com/kelseyhightower/envconfig v1.4.0 12 | github.com/onsi/ginkgo v1.16.5 13 | github.com/onsi/gomega v1.22.1 14 | github.com/ory/dockertest/v3 v3.9.1 15 | github.com/sirupsen/logrus v1.9.0 16 | golang.org/x/sync v0.1.0 17 | google.golang.org/grpc v1.50.0 18 | google.golang.org/protobuf v1.28.1 19 | logur.dev/adapter/logrus v0.5.0 20 | ) 21 | 22 | require ( 23 | cloud.google.com/go v0.81.0 // indirect 24 | github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78 // indirect 25 | github.com/Microsoft/go-winio v0.5.2 // indirect 26 | github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect 27 | github.com/armon/go-metrics v0.3.9 // indirect 28 | github.com/armon/go-radix v1.0.0 // indirect 29 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.23 // indirect 30 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.17 // indirect 31 | github.com/aws/smithy-go v1.13.3 // indirect 32 | github.com/beorn7/perks v1.0.1 // indirect 33 | github.com/bgentry/speakeasy v0.1.0 // indirect 34 | github.com/cenkalti/backoff/v3 v3.0.0 // indirect 35 | github.com/cenkalti/backoff/v4 v4.1.3 // indirect 36 | github.com/census-instrumentation/opencensus-proto v0.3.0 // indirect 37 | github.com/cespare/xxhash/v2 v2.1.1 // indirect 38 | github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4 // indirect 39 | github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1 // indirect 40 | github.com/containerd/continuity v0.3.0 // indirect 41 | github.com/coreos/go-semver v0.3.0 // indirect 42 | github.com/coreos/go-systemd/v22 v22.3.2 // indirect 43 | github.com/cpuguy83/go-md2man/v2 v2.0.0 // indirect 44 | github.com/docker/cli v20.10.14+incompatible // indirect 45 | github.com/docker/docker v20.10.7+incompatible // indirect 46 | github.com/docker/go-connections v0.4.0 // indirect 47 | github.com/docker/go-units v0.4.0 // indirect 48 | github.com/dustin/go-humanize v1.0.0 // indirect 49 | github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1 // indirect 50 | github.com/envoyproxy/protoc-gen-validate v0.6.1 // indirect 51 | github.com/fatih/color v1.9.0 // indirect 52 | github.com/form3tech-oss/jwt-go v3.2.3+incompatible // indirect 53 | github.com/fsnotify/fsnotify v1.4.9 // indirect 54 | github.com/fullstorydev/grpcurl v1.8.1 // indirect 55 | github.com/go-sql-driver/mysql v1.7.0 // indirect 56 | github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect 57 | github.com/gogo/protobuf v1.3.2 // indirect 58 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 59 | github.com/golang/mock v1.5.0 // indirect 60 | github.com/golang/snappy v0.0.4 // indirect 61 | github.com/google/btree v1.0.1 // indirect 62 | github.com/google/certificate-transparency-go v1.1.2-0.20210511102531-373a877eec92 // indirect 63 | github.com/google/go-cmp v0.5.8 // indirect 64 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 65 | github.com/gorilla/websocket v1.4.2 // indirect 66 | github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect 67 | github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 // indirect 68 | github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect 69 | github.com/hashicorp/errwrap v1.1.0 // indirect 70 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 71 | github.com/hashicorp/go-hclog v0.16.2 // indirect 72 | github.com/hashicorp/go-immutable-radix v1.3.1 // indirect 73 | github.com/hashicorp/go-multierror v1.1.1 // indirect 74 | github.com/hashicorp/go-plugin v1.4.3 // indirect 75 | github.com/hashicorp/go-retryablehttp v0.6.6 // indirect 76 | github.com/hashicorp/go-rootcerts v1.0.2 // indirect 77 | github.com/hashicorp/go-secure-stdlib/mlock v0.1.1 // indirect 78 | github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect 79 | github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 80 | github.com/hashicorp/go-sockaddr v1.0.2 // indirect 81 | github.com/hashicorp/go-uuid v1.0.2 // indirect 82 | github.com/hashicorp/go-version v1.2.0 // indirect 83 | github.com/hashicorp/golang-lru v0.5.4 // indirect 84 | github.com/hashicorp/hcl v1.0.0 // indirect 85 | github.com/hashicorp/vault/sdk v0.6.0 // indirect 86 | github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb // indirect 87 | github.com/imdario/mergo v0.3.12 // indirect 88 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 89 | github.com/jhump/protoreflect v1.8.2 // indirect 90 | github.com/jmespath/go-jmespath v0.4.0 // indirect 91 | github.com/jmoiron/sqlx v1.3.3 // indirect 92 | github.com/jonboulle/clockwork v0.2.2 // indirect 93 | github.com/json-iterator/go v1.1.11 // indirect 94 | github.com/lib/pq v1.10.7 // indirect 95 | github.com/mattn/go-colorable v0.1.6 // indirect 96 | github.com/mattn/go-isatty v0.0.12 // indirect 97 | github.com/mattn/go-runewidth v0.0.12 // indirect 98 | github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect 99 | github.com/mitchellh/copystructure v1.0.0 // indirect 100 | github.com/mitchellh/go-homedir v1.1.0 // indirect 101 | github.com/mitchellh/go-testing-interface v1.0.0 // indirect 102 | github.com/mitchellh/mapstructure v1.5.0 // indirect 103 | github.com/mitchellh/reflectwalk v1.0.1 // indirect 104 | github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect 105 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 106 | github.com/modern-go/reflect2 v1.0.1 // indirect 107 | github.com/nxadm/tail v1.4.8 // indirect 108 | github.com/oklog/run v1.0.0 // indirect 109 | github.com/olekukonko/tablewriter v0.0.5 // indirect 110 | github.com/opencontainers/go-digest v1.0.0 // indirect 111 | github.com/opencontainers/image-spec v1.0.2 // indirect 112 | github.com/opencontainers/runc v1.1.5 // indirect 113 | github.com/pierrec/lz4 v2.5.2+incompatible // indirect 114 | github.com/pkg/errors v0.9.1 // indirect 115 | github.com/prometheus/client_golang v1.11.1 // indirect 116 | github.com/prometheus/client_model v0.2.0 // indirect 117 | github.com/prometheus/common v0.26.0 // indirect 118 | github.com/prometheus/procfs v0.6.0 // indirect 119 | github.com/rivo/uniseg v0.2.0 // indirect 120 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 121 | github.com/ryanuber/go-glob v1.0.0 // indirect 122 | github.com/soheilhy/cmux v0.1.5 // indirect 123 | github.com/spf13/cobra v1.1.3 // indirect 124 | github.com/spf13/pflag v1.0.5 // indirect 125 | github.com/tmc/grpc-websocket-proxy v0.0.0-20201229170055-e5319fda7802 // indirect 126 | github.com/urfave/cli v1.22.5 // indirect 127 | github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b // indirect 128 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect 129 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 130 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 131 | github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 // indirect 132 | github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc // indirect 133 | github.com/zmap/zlint/v3 v3.1.0 // indirect 134 | go.etcd.io/bbolt v1.3.5 // indirect 135 | go.etcd.io/etcd/api/v3 v3.5.0-alpha.0 // indirect 136 | go.etcd.io/etcd/client/v2 v2.305.0-alpha.0 // indirect 137 | go.etcd.io/etcd/client/v3 v3.5.0-alpha.0 // indirect 138 | go.etcd.io/etcd/etcdctl/v3 v3.5.0-alpha.0 // indirect 139 | go.etcd.io/etcd/pkg/v3 v3.5.0-alpha.0 // indirect 140 | go.etcd.io/etcd/raft/v3 v3.5.0-alpha.0 // indirect 141 | go.etcd.io/etcd/server/v3 v3.5.0-alpha.0 // indirect 142 | go.etcd.io/etcd/tests/v3 v3.5.0-alpha.0 // indirect 143 | go.etcd.io/etcd/v3 v3.5.0-alpha.0 // indirect 144 | go.uber.org/atomic v1.9.0 // indirect 145 | go.uber.org/multierr v1.7.0 // indirect 146 | go.uber.org/zap v1.16.0 // indirect 147 | golang.org/x/crypto v0.0.0-20220824171710-5757bc0c5503 // indirect 148 | golang.org/x/mod v0.7.0 // indirect 149 | golang.org/x/net v0.7.0 // indirect 150 | golang.org/x/oauth2 v0.0.0-20210427180440-81ed05c6b58c // indirect 151 | golang.org/x/sys v0.5.0 // indirect 152 | golang.org/x/text v0.7.0 // indirect 153 | golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba // indirect 154 | golang.org/x/tools v0.5.0 // indirect 155 | google.golang.org/appengine v1.6.7 // indirect 156 | google.golang.org/genproto v0.0.0-20210510173355-fb37daa5cd7a // indirect 157 | gopkg.in/cheggaaa/pb.v1 v1.0.28 // indirect 158 | gopkg.in/square/go-jose.v2 v2.5.1 // indirect 159 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 160 | gopkg.in/yaml.v2 v2.4.0 // indirect 161 | gopkg.in/yaml.v3 v3.0.1 // indirect 162 | logur.dev/logur v0.16.1 // indirect 163 | sigs.k8s.io/yaml v1.2.0 // indirect 164 | ) 165 | -------------------------------------------------------------------------------- /internal/csr/csr.go: -------------------------------------------------------------------------------- 1 | package csr 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/x509" 6 | "crypto/x509/pkix" 7 | "encoding/pem" 8 | 9 | "github.com/johanbrandhorst/certify" 10 | "github.com/johanbrandhorst/certify/internal/keys" 11 | ) 12 | 13 | // FromCertConfig creates a CSR and private key from the input config and common name. 14 | // It returns the CSR and private key in PEM format. 15 | func FromCertConfig(commonName string, conf *certify.CertConfig) ([]byte, []byte, error) { 16 | pk, err := conf.KeyGenerator.Generate() 17 | if err != nil { 18 | return nil, nil, err 19 | } 20 | 21 | keyPEM, err := keys.Marshal(pk) 22 | if err != nil { 23 | return nil, nil, err 24 | } 25 | 26 | template := &x509.CertificateRequest{ 27 | Subject: pkix.Name{ 28 | CommonName: commonName, 29 | }, 30 | } 31 | 32 | if conf != nil { 33 | template.DNSNames = conf.SubjectAlternativeNames 34 | template.IPAddresses = conf.IPSubjectAlternativeNames 35 | template.URIs = conf.URISubjectAlternativeNames 36 | } 37 | 38 | csr, err := x509.CreateCertificateRequest(rand.Reader, template, pk) 39 | if err != nil { 40 | return nil, nil, err 41 | } 42 | 43 | csrPEM := pem.EncodeToMemory(&pem.Block{ 44 | Type: "CERTIFICATE REQUEST", 45 | Bytes: csr, 46 | }) 47 | 48 | return csrPEM, keyPEM, nil 49 | } 50 | -------------------------------------------------------------------------------- /internal/csr/csr_suite_test.go: -------------------------------------------------------------------------------- 1 | package csr_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestCSR(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "CSR Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/csr/csr_test.go: -------------------------------------------------------------------------------- 1 | package csr_test 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "crypto/x509" 9 | "encoding/pem" 10 | "net" 11 | "net/url" 12 | 13 | . "github.com/onsi/ginkgo" 14 | . "github.com/onsi/gomega" 15 | 16 | "github.com/johanbrandhorst/certify" 17 | "github.com/johanbrandhorst/certify/internal/csr" 18 | ) 19 | 20 | var _ = Describe("FromCertConfig", func() { 21 | It("Generates a CSR and a Key", func() { 22 | conf := &certify.CertConfig{ 23 | SubjectAlternativeNames: []string{"extraname.com"}, 24 | IPSubjectAlternativeNames: []net.IP{net.IPv4(1, 2, 3, 4)}, 25 | URISubjectAlternativeNames: []*url.URL{{Scheme: "https", Host: "example.com"}}, 26 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 27 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 28 | }), 29 | } 30 | csrPEM, keyPEM, err := csr.FromCertConfig("myserver.com", conf) 31 | Expect(err).To(Succeed()) 32 | 33 | csrBlock, _ := pem.Decode(csrPEM) 34 | csr, err := x509.ParseCertificateRequest(csrBlock.Bytes) 35 | Expect(err).To(Succeed()) 36 | Expect(csr.PublicKeyAlgorithm).To(Equal(x509.ECDSA)) 37 | Expect(csr.PublicKey).To(BeAssignableToTypeOf(&ecdsa.PublicKey{})) 38 | Expect(csr.Subject.CommonName).To(Equal("myserver.com")) 39 | Expect(csr.DNSNames).To(Equal(conf.SubjectAlternativeNames)) 40 | Expect(csr.URIs).To(Equal(conf.URISubjectAlternativeNames)) 41 | for i, ip := range csr.IPAddresses { 42 | Expect(ip.Equal(conf.IPSubjectAlternativeNames[i])).To(BeTrue()) 43 | } 44 | 45 | keyBlock, _ := pem.Decode(keyPEM) 46 | key, err := x509.ParseECPrivateKey(keyBlock.Bytes) 47 | Expect(err).To(Succeed()) 48 | Expect(key.Params().BitSize).To(Equal(256)) 49 | }) 50 | }) 51 | 52 | type keyGeneratorFunc func() (crypto.PrivateKey, error) 53 | 54 | func (kgf keyGeneratorFunc) Generate() (crypto.PrivateKey, error) { 55 | return kgf() 56 | } 57 | -------------------------------------------------------------------------------- /internal/keys/keys.go: -------------------------------------------------------------------------------- 1 | package keys 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/rsa" 7 | "crypto/x509" 8 | "encoding/pem" 9 | "errors" 10 | ) 11 | 12 | // Marshal marshals a private key to PEM format. 13 | func Marshal(pk crypto.PrivateKey) ([]byte, error) { 14 | switch pk := pk.(type) { 15 | case *rsa.PrivateKey: 16 | keyBytes := x509.MarshalPKCS1PrivateKey(pk) 17 | block := pem.Block{ 18 | Type: "RSA PRIVATE KEY", 19 | Bytes: keyBytes, 20 | } 21 | return pem.EncodeToMemory(&block), nil 22 | case *ecdsa.PrivateKey: 23 | keyBytes, err := x509.MarshalECPrivateKey(pk) 24 | if err != nil { 25 | return nil, err 26 | } 27 | block := pem.Block{ 28 | Type: "EC PRIVATE KEY", 29 | Bytes: keyBytes, 30 | } 31 | return pem.EncodeToMemory(&block), nil 32 | } 33 | 34 | return nil, errors.New("unsupported private key type") 35 | } 36 | -------------------------------------------------------------------------------- /internal/tools/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | // +build tools 3 | 4 | package tools 5 | 6 | import ( 7 | _ "github.com/golang/protobuf/protoc-gen-go" 8 | _ "github.com/onsi/ginkgo/ginkgo" 9 | ) 10 | -------------------------------------------------------------------------------- /issuer.go: -------------------------------------------------------------------------------- 1 | package certify 2 | 3 | import ( 4 | "context" 5 | "crypto" 6 | "crypto/tls" 7 | "net" 8 | "net/url" 9 | ) 10 | 11 | // Issuer is the interface that must be implemented 12 | // by certificate issuers. 13 | type Issuer interface { 14 | Issue(context.Context, string, *CertConfig) (*tls.Certificate, error) 15 | } 16 | 17 | // KeyGenerator defines an interface used to generate a private key. 18 | type KeyGenerator interface { 19 | Generate() (crypto.PrivateKey, error) 20 | } 21 | 22 | // CertConfig configures the specifics of the certificate 23 | // requested from the Issuer. 24 | type CertConfig struct { 25 | SubjectAlternativeNames []string 26 | IPSubjectAlternativeNames []net.IP 27 | URISubjectAlternativeNames []*url.URL 28 | // KeyGenerator is used to create new private keys 29 | // for CSR requests. If not defined, defaults to ECDSA P256. 30 | // Only ECDSA and RSA keys are supported. 31 | // This is guaranteed to be provided in Issue calls. 32 | KeyGenerator KeyGenerator 33 | } 34 | 35 | // Clone makes a deep copy of the CertConfig. 36 | func (cc *CertConfig) Clone() *CertConfig { 37 | newCC := new(CertConfig) 38 | if cc == nil { 39 | return newCC 40 | } 41 | 42 | newCC.SubjectAlternativeNames = cc.SubjectAlternativeNames 43 | newCC.IPSubjectAlternativeNames = cc.IPSubjectAlternativeNames 44 | newCC.URISubjectAlternativeNames = cc.URISubjectAlternativeNames 45 | newCC.KeyGenerator = cc.KeyGenerator 46 | return newCC 47 | } 48 | 49 | func (cc *CertConfig) appendName(name string) { 50 | if ip := net.ParseIP(name); ip != nil { 51 | cc.IPSubjectAlternativeNames = append(cc.IPSubjectAlternativeNames, ip) 52 | } else { 53 | cc.SubjectAlternativeNames = append(cc.SubjectAlternativeNames, name) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /issuers/aws/.gitignore: -------------------------------------------------------------------------------- 1 | aws_secret_test.go 2 | -------------------------------------------------------------------------------- /issuers/aws/aws.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "errors" 9 | "fmt" 10 | "sync" 11 | "time" 12 | 13 | "github.com/aws/aws-sdk-go-v2/aws" 14 | "github.com/aws/aws-sdk-go-v2/service/acmpca" 15 | "github.com/aws/aws-sdk-go-v2/service/acmpca/types" 16 | 17 | "github.com/johanbrandhorst/certify" 18 | "github.com/johanbrandhorst/certify/internal/csr" 19 | ) 20 | 21 | // Issuer implements the Issuer interface with a 22 | // AWS Certificate Manager Private Certificate Authority backend. 23 | // 24 | // Client and CertificateAuthorityARN are required. 25 | type Issuer struct { 26 | // Client is a pre-created ACMPCA client. It can be created 27 | // via, for example: 28 | // conf, err := external.LoadDefaultAWSConfig() 29 | // if err != nil { 30 | // return nil, err 31 | // } 32 | // conf.Region = endpoints.EuWest2RegionID 33 | // conf.Credentials = aws.NewStaticCredentialsProvider("YOURKEY", "YOURKEYSECRET", "") 34 | // cli := acmpca.New(conf) 35 | Client *acmpca.Client 36 | // CertificateAuthorityARN specifies the ARN of a pre-created CA 37 | // which will be used to issue the certificates. 38 | CertificateAuthorityARN string 39 | 40 | // TimeToLive configures the lifetime of certificates 41 | // requested from the AWS CA, in number of days. 42 | // If unset, defaults to 30 days. 43 | TimeToLive int 44 | 45 | initOnce sync.Once 46 | initErr error 47 | caCert *x509.Certificate 48 | signAlgo types.SigningAlgorithm 49 | waiter *acmpca.CertificateIssuedWaiter 50 | } 51 | 52 | // Issue issues a certificate from the configured AWS CA backend. 53 | func (i Issuer) Issue(ctx context.Context, commonName string, conf *certify.CertConfig) (*tls.Certificate, error) { 54 | i.initOnce.Do(func() { 55 | i.waiter = acmpca.NewCertificateIssuedWaiter(i.Client) 56 | 57 | caResp, err := i.Client.GetCertificateAuthorityCertificate(ctx, &acmpca.GetCertificateAuthorityCertificateInput{ 58 | CertificateAuthorityArn: aws.String(i.CertificateAuthorityARN), 59 | }) 60 | if err != nil { 61 | i.initErr = err 62 | return 63 | } 64 | 65 | caBlock, _ := pem.Decode([]byte(*caResp.Certificate)) 66 | if caBlock == nil { 67 | i.initErr = errors.New("could not parse AWS CA cert") 68 | return 69 | } 70 | 71 | if caBlock.Type != "CERTIFICATE" { 72 | i.initErr = errors.New("saw unexpected PEM Type while requesting AWS CA cert: " + caBlock.Type) 73 | return 74 | } 75 | 76 | i.caCert, err = x509.ParseCertificate(caBlock.Bytes) 77 | if err != nil { 78 | i.initErr = err 79 | return 80 | } 81 | 82 | switch i.caCert.SignatureAlgorithm { 83 | case x509.SHA256WithRSA: 84 | i.signAlgo = types.SigningAlgorithmSha256withrsa 85 | case x509.SHA384WithRSA: 86 | i.signAlgo = types.SigningAlgorithmSha384withrsa 87 | case x509.SHA512WithRSA: 88 | i.signAlgo = types.SigningAlgorithmSha512withrsa 89 | case x509.ECDSAWithSHA256: 90 | i.signAlgo = types.SigningAlgorithmSha256withecdsa 91 | case x509.ECDSAWithSHA384: 92 | i.signAlgo = types.SigningAlgorithmSha384withecdsa 93 | case x509.ECDSAWithSHA512: 94 | i.signAlgo = types.SigningAlgorithmSha512withecdsa 95 | default: 96 | i.initErr = fmt.Errorf("unsupported CA cert signing algorithm: %T", i.caCert.SignatureAlgorithm) 97 | return 98 | } 99 | }) 100 | if i.initErr != nil { 101 | return nil, i.initErr 102 | } 103 | 104 | csrPEM, keyPEM, err := csr.FromCertConfig(commonName, conf) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | // Default to 30 days if unset. 110 | ttl := int64(30) 111 | if i.TimeToLive > 0 { 112 | ttl = int64(i.TimeToLive) 113 | } 114 | 115 | issueResp, err := i.Client.IssueCertificate(ctx, &acmpca.IssueCertificateInput{ 116 | CertificateAuthorityArn: aws.String(i.CertificateAuthorityARN), 117 | Csr: csrPEM, 118 | SigningAlgorithm: i.signAlgo, 119 | Validity: &types.Validity{ 120 | Type: types.ValidityPeriodTypeDays, 121 | Value: aws.Int64(ttl), 122 | }, 123 | }) 124 | if err != nil { 125 | return nil, err 126 | } 127 | 128 | err = i.waiter.Wait(ctx, &acmpca.GetCertificateInput{ 129 | CertificateArn: issueResp.CertificateArn, 130 | CertificateAuthorityArn: aws.String(i.CertificateAuthorityARN), 131 | }, time.Minute) 132 | if err != nil { 133 | return nil, err 134 | } 135 | 136 | getReq := &acmpca.GetCertificateInput{ 137 | CertificateArn: issueResp.CertificateArn, 138 | CertificateAuthorityArn: aws.String(i.CertificateAuthorityARN), 139 | } 140 | 141 | cert, err := i.Client.GetCertificate(ctx, getReq) 142 | if err != nil { 143 | return nil, err 144 | } 145 | 146 | caChainPEM := append(append([]byte(*cert.Certificate), '\n'), []byte(*cert.CertificateChain)...) 147 | 148 | tlsCert, err := tls.X509KeyPair(caChainPEM, keyPEM) 149 | if err != nil { 150 | return nil, err 151 | } 152 | 153 | // This can't error since it's called in tls.X509KeyPair above successfully 154 | tlsCert.Leaf, _ = x509.ParseCertificate(tlsCert.Certificate[0]) 155 | return &tlsCert, nil 156 | } 157 | -------------------------------------------------------------------------------- /issuers/aws/aws_test.go: -------------------------------------------------------------------------------- 1 | package aws_test 2 | 3 | import ( 4 | "context" 5 | "crypto" 6 | "crypto/ecdsa" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/rsa" 10 | "crypto/x509" 11 | "crypto/x509/pkix" 12 | "encoding/json" 13 | "encoding/pem" 14 | "math/big" 15 | "net" 16 | "net/http" 17 | "net/http/httptest" 18 | "testing" 19 | "time" 20 | 21 | api "github.com/aws/aws-sdk-go-v2/aws" 22 | "github.com/aws/aws-sdk-go-v2/service/acmpca" 23 | "github.com/aws/aws-sdk-go-v2/service/acmpca/types" 24 | 25 | "github.com/johanbrandhorst/certify" 26 | "github.com/johanbrandhorst/certify/issuers/aws" 27 | ) 28 | 29 | func TestIssuer(t *testing.T) { 30 | t.Run("It issues a certificate", func(t *testing.T) { 31 | caARN := "someARN" 32 | certARN := "anotherARN" 33 | caCert, caKey, err := generateCertAndKey() 34 | if err != nil { 35 | t.Fatal(err) 36 | } 37 | ttl := 25 38 | server := httptest.NewTLSServer(&fakeACMPCA{ 39 | t: t, 40 | certARN: certARN, 41 | caARN: caARN, 42 | caCert: caCert, 43 | caKey: caKey, 44 | validityDays: ttl, 45 | }) 46 | 47 | client := acmpca.NewFromConfig(api.Config{ 48 | HTTPClient: server.Client(), 49 | EndpointResolver: api.EndpointResolverFunc(func(service, region string) (api.Endpoint, error) { 50 | return api.Endpoint{ 51 | URL: server.URL, 52 | }, nil 53 | }), 54 | }) 55 | iss := &aws.Issuer{ 56 | CertificateAuthorityARN: caARN, 57 | Client: client, 58 | TimeToLive: ttl, 59 | } 60 | cn := "somename.com" 61 | conf := &certify.CertConfig{ 62 | SubjectAlternativeNames: []string{"extraname.com", "otherextraname.com"}, 63 | IPSubjectAlternativeNames: []net.IP{net.IPv4(1, 2, 3, 4), net.IPv6loopback}, 64 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 65 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 66 | }), 67 | } 68 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 69 | if err != nil { 70 | t.Fatal(err) 71 | } 72 | 73 | if tlsCert.Leaf == nil { 74 | t.Fatal("tlsCert.Leaf should be populated by Issue to track expiry") 75 | } 76 | if tlsCert.Leaf.Subject.CommonName != cn { 77 | t.Fatalf("Unexpected Common name %s, wanted %s", tlsCert.Leaf.Subject.CommonName, cn) 78 | } 79 | if len(tlsCert.Leaf.DNSNames) != len(conf.SubjectAlternativeNames) { 80 | t.Fatalf("Unexpected number of DNS names set, got %d wanted %d", len(tlsCert.Leaf.DNSNames), len(conf.SubjectAlternativeNames)) 81 | } 82 | for i, dnsName := range tlsCert.Leaf.DNSNames { 83 | if conf.SubjectAlternativeNames[i] != dnsName { 84 | t.Fatalf("Unexpected DNS name %s, wanted %s", dnsName, conf.SubjectAlternativeNames[i]) 85 | } 86 | } 87 | if len(tlsCert.Leaf.IPAddresses) != len(conf.IPSubjectAlternativeNames) { 88 | t.Fatalf("Unexpected number of IP addresses set, got %d wanted %d", len(tlsCert.Leaf.IPAddresses), len(conf.IPSubjectAlternativeNames)) 89 | } 90 | for i, ip := range tlsCert.Leaf.IPAddresses { 91 | if !ip.Equal(conf.IPSubjectAlternativeNames[i]) { 92 | t.Fatalf("Unexpected IP address %s, wanted %s", ip, conf.IPSubjectAlternativeNames[i]) 93 | } 94 | } 95 | 96 | // Check that chain is included 97 | if len(tlsCert.Certificate) != 2 { 98 | t.Fatalf("Unexpected number of certificates in chain, got %d wanted %d", len(tlsCert.Certificate), 2) 99 | } 100 | crt, err := x509.ParseCertificate(tlsCert.Certificate[1]) 101 | if err != nil { 102 | t.Fatal(err) 103 | } 104 | if crt.Subject.SerialNumber != tlsCert.Leaf.Issuer.SerialNumber { 105 | t.Fatalf("Unexpected serial number, got %s wanted %s", crt.Subject.SerialNumber, tlsCert.Leaf.Issuer.SerialNumber) 106 | } 107 | if tlsCert.Leaf.NotBefore.After(time.Now()) { 108 | t.Fatalf("Unexpected NotBefore time, got %s wanted > %s", tlsCert.Leaf.NotBefore, time.Now()) 109 | } 110 | if tlsCert.Leaf.NotAfter.Before(time.Now().AddDate(0, 0, iss.TimeToLive).Add(-5*time.Second)) || 111 | tlsCert.Leaf.NotAfter.After(time.Now().AddDate(0, 0, iss.TimeToLive).Add(5*time.Second)) { 112 | t.Fatalf( 113 | "Unexpected NotAfter time, got %s wanted in [%s, %s]", 114 | tlsCert.Leaf.NotAfter, 115 | time.Now().AddDate(0, 0, iss.TimeToLive).Add(-5*time.Second), 116 | time.Now().AddDate(0, 0, iss.TimeToLive).Add(5*time.Second), 117 | ) 118 | } 119 | }) 120 | } 121 | 122 | type fakeACMPCA struct { 123 | t *testing.T 124 | caARN string 125 | certARN string 126 | caCert *cert 127 | caKey *key 128 | validityDays int 129 | 130 | signedCertPEM []byte 131 | } 132 | 133 | func (f *fakeACMPCA) ServeHTTP(w http.ResponseWriter, r *http.Request) { 134 | switch r.Header.Get("X-Amz-Target") { 135 | case "ACMPrivateCA.GetCertificateAuthorityCertificate": 136 | f.ServeGetCertificateAuthorityCertificate(w, r) 137 | return 138 | case "ACMPrivateCA.IssueCertificate": 139 | f.ServeIssueCertificate(w, r) 140 | return 141 | case "ACMPrivateCA.GetCertificate": 142 | f.ServeGetCertificate(w, r) 143 | return 144 | default: 145 | http.Error(w, "not found", http.StatusNotFound) 146 | } 147 | } 148 | 149 | func (f *fakeACMPCA) ServeGetCertificateAuthorityCertificate(w http.ResponseWriter, r *http.Request) { 150 | input := struct { 151 | CertificateAuthorityARN string `json:"CertificateAuthorityARN,omitempty"` 152 | }{} 153 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 154 | http.Error(w, err.Error(), http.StatusBadRequest) 155 | return 156 | } 157 | if input.CertificateAuthorityARN != f.caARN { 158 | http.Error(w, "unknown CA ARN", http.StatusNotFound) 159 | return 160 | } 161 | output := struct { 162 | Certificate string `json:"Certificate,omitempty"` 163 | }{ 164 | Certificate: string(f.caCert.pem), 165 | } 166 | if err := json.NewEncoder(w).Encode(&output); err != nil { 167 | http.Error(w, err.Error(), http.StatusInternalServerError) 168 | return 169 | } 170 | } 171 | 172 | func (f *fakeACMPCA) ServeIssueCertificate(w http.ResponseWriter, r *http.Request) { 173 | input := struct { 174 | CertificateAuthorityARN string `json:"CertificateAuthorityARN,omitempty"` 175 | CSRPem []byte `json:"Csr,omitempty"` 176 | SigningAlgorithm types.SigningAlgorithm `json:"SigningAlgorithm,omitempty"` 177 | Validity types.Validity `json:"Validity,omitempty"` 178 | }{} 179 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 180 | http.Error(w, err.Error(), http.StatusBadRequest) 181 | return 182 | } 183 | if input.CertificateAuthorityARN != f.caARN { 184 | http.Error(w, "unknown CA ARN", http.StatusNotFound) 185 | return 186 | } 187 | if input.Validity.Type != types.ValidityPeriodTypeDays { 188 | http.Error(w, "unexpected validity period type", http.StatusBadRequest) 189 | return 190 | } 191 | if int(*input.Validity.Value) != f.validityDays { 192 | http.Error(w, "unexpected validity period", http.StatusBadRequest) 193 | return 194 | } 195 | block, _ := pem.Decode(input.CSRPem) 196 | if block == nil { 197 | http.Error(w, "block was nil", http.StatusInternalServerError) 198 | return 199 | } 200 | csr, err := x509.ParseCertificateRequest(block.Bytes) 201 | if err != nil { 202 | http.Error(w, err.Error(), http.StatusInternalServerError) 203 | return 204 | } 205 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 206 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 207 | if err != nil { 208 | http.Error(w, err.Error(), http.StatusInternalServerError) 209 | return 210 | } 211 | template := &x509.Certificate{ 212 | SerialNumber: serialNumber, 213 | Subject: csr.Subject, 214 | PublicKeyAlgorithm: csr.PublicKeyAlgorithm, 215 | PublicKey: csr.PublicKey, 216 | SignatureAlgorithm: x509.SHA256WithRSA, 217 | DNSNames: csr.DNSNames, 218 | IPAddresses: csr.IPAddresses, 219 | EmailAddresses: csr.EmailAddresses, 220 | URIs: csr.URIs, 221 | NotBefore: time.Now(), 222 | NotAfter: time.Now().AddDate(0, 0, int(*input.Validity.Value)), 223 | } 224 | cert, err := x509.CreateCertificate(rand.Reader, template, f.caCert.cert, csr.PublicKey, f.caKey.key) 225 | if err != nil { 226 | http.Error(w, err.Error(), http.StatusInternalServerError) 227 | return 228 | } 229 | f.signedCertPEM = pem.EncodeToMemory(&pem.Block{ 230 | Type: "CERTIFICATE", 231 | Bytes: cert, 232 | }) 233 | 234 | output := struct { 235 | CertificateARN string `json:"CertificateArn,omitempty"` 236 | }{ 237 | CertificateARN: f.certARN, 238 | } 239 | if err := json.NewEncoder(w).Encode(&output); err != nil { 240 | http.Error(w, err.Error(), http.StatusInternalServerError) 241 | return 242 | } 243 | } 244 | 245 | func (f *fakeACMPCA) ServeGetCertificate(w http.ResponseWriter, r *http.Request) { 246 | input := struct { 247 | CertificateAuthorityARN string `json:"CertificateAuthorityARN,omitempty"` 248 | CertificateARN string `json:"CertificateARN,omitempty"` 249 | }{} 250 | if err := json.NewDecoder(r.Body).Decode(&input); err != nil { 251 | http.Error(w, err.Error(), http.StatusBadRequest) 252 | return 253 | } 254 | if input.CertificateAuthorityARN != f.caARN { 255 | http.Error(w, "unknown CA ARN", http.StatusNotFound) 256 | return 257 | } 258 | if input.CertificateARN != f.certARN { 259 | http.Error(w, "unknown cert ARN", http.StatusNotFound) 260 | return 261 | } 262 | output := struct { 263 | Certificate string `json:"Certificate,omitempty"` 264 | CertificateChain string `json:"CertificateChain,omitempty"` 265 | }{ 266 | Certificate: string(f.signedCertPEM), 267 | CertificateChain: string(f.caCert.pem), 268 | } 269 | if err := json.NewEncoder(w).Encode(&output); err != nil { 270 | http.Error(w, err.Error(), http.StatusInternalServerError) 271 | return 272 | } 273 | } 274 | 275 | type keyGeneratorFunc func() (crypto.PrivateKey, error) 276 | 277 | func (kgf keyGeneratorFunc) Generate() (crypto.PrivateKey, error) { 278 | return kgf() 279 | } 280 | 281 | type key struct { 282 | pem []byte 283 | key *rsa.PrivateKey 284 | } 285 | 286 | type cert struct { 287 | pem []byte 288 | cert *x509.Certificate 289 | } 290 | 291 | func generateCertAndKey() (*cert, *key, error) { 292 | priv, err := rsa.GenerateKey(rand.Reader, 2048) 293 | if err != nil { 294 | return nil, nil, err 295 | } 296 | notBefore := time.Now() 297 | notAfter := notBefore.Add(time.Hour) 298 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 299 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 300 | if err != nil { 301 | return nil, nil, err 302 | } 303 | template := x509.Certificate{ 304 | SerialNumber: serialNumber, 305 | Subject: pkix.Name{ 306 | CommonName: "Certify Test Cert", 307 | }, 308 | NotBefore: notBefore, 309 | NotAfter: notAfter, 310 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 311 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 312 | BasicConstraintsValid: true, 313 | } 314 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, priv.Public(), priv) 315 | if err != nil { 316 | return nil, nil, err 317 | } 318 | 319 | k := &key{ 320 | key: priv, 321 | pem: pem.EncodeToMemory(&pem.Block{ 322 | Type: "RSA PRIVATE KEY", 323 | Bytes: x509.MarshalPKCS1PrivateKey(priv), 324 | }), 325 | } 326 | crt, err := x509.ParseCertificate(derBytes) 327 | if err != nil { 328 | return nil, nil, err 329 | } 330 | c := &cert{ 331 | cert: crt, 332 | pem: pem.EncodeToMemory(&pem.Block{ 333 | Type: "CERTIFICATE", 334 | Bytes: derBytes, 335 | }), 336 | } 337 | return c, k, nil 338 | } 339 | -------------------------------------------------------------------------------- /issuers/cfssl/cfssl.go: -------------------------------------------------------------------------------- 1 | package cfssl 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "encoding/json" 8 | "net/http" 9 | "net/url" 10 | 11 | "github.com/cloudflare/cfssl/api/client" 12 | "github.com/cloudflare/cfssl/auth" 13 | "github.com/cloudflare/cfssl/signer" 14 | 15 | "github.com/johanbrandhorst/certify" 16 | "github.com/johanbrandhorst/certify/internal/csr" 17 | ) 18 | 19 | // Issuer implements the Issuer interface 20 | // with a Cloudflare CFSSL CA server backend. 21 | // 22 | // URL is required. 23 | type Issuer struct { 24 | // URL specifies the URL to the CFSSL server. 25 | URL *url.URL 26 | // TLSConfig allows configuration of the TLS config 27 | // used when connecting to the CFSSL server. 28 | TLSConfig *tls.Config 29 | // Profile is the profile on the CFSSL server 30 | // that should be used. If unset, the default 31 | // profile will be used. 32 | Profile string 33 | // Auth optionally configures the authentication 34 | // that should be used. 35 | Auth auth.Provider 36 | 37 | remote client.Remote 38 | remoteCertPEM []byte 39 | } 40 | 41 | // FromClient returns an Issuer using the provided CFSSL API client. 42 | // Any changes to the issuers properties must be done before using it. 43 | func FromClient(v client.Remote) (*Issuer, error) { 44 | i := &Issuer{ 45 | remote: v, 46 | } 47 | 48 | // Use the Info endpoint as a PING to check server availability 49 | resp, err := i.remote.Info([]byte(`{}`)) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | i.remoteCertPEM = []byte(resp.Certificate) 55 | return i, nil 56 | } 57 | 58 | // connect and sends a request to validate server availability and 59 | // cache its cert. 60 | func (i *Issuer) connect(ctx context.Context) error { 61 | if i.TLSConfig != nil { 62 | i.remote = client.NewServerTLS(i.URL.String(), i.TLSConfig) 63 | } else { 64 | i.remote = client.NewServer(i.URL.String()) 65 | } 66 | // Add context to requests 67 | i.remote.SetReqModifier(func(req *http.Request, _ []byte) { 68 | *req = *req.WithContext(ctx) 69 | }) 70 | 71 | // Use the Info endpoint as a PING to check server availability 72 | resp, err := i.remote.Info([]byte(`{}`)) 73 | if err != nil { 74 | return err 75 | } 76 | 77 | i.remoteCertPEM = []byte(resp.Certificate) 78 | 79 | return nil 80 | } 81 | 82 | // Issue issues a certificate with the provided options. 83 | func (i *Issuer) Issue(ctx context.Context, commonName string, conf *certify.CertConfig) (*tls.Certificate, error) { 84 | if i.remote == nil { 85 | err := i.connect(ctx) 86 | if err != nil { 87 | return nil, err 88 | } 89 | } 90 | 91 | // Add context to requests 92 | i.remote.SetReqModifier(func(req *http.Request, _ []byte) { 93 | *req = *req.WithContext(ctx) 94 | }) 95 | 96 | csrPEM, keyPEM, err := csr.FromCertConfig(commonName, conf) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | req := signer.SignRequest{ 102 | Request: string(csrPEM), 103 | Profile: i.Profile, 104 | } 105 | 106 | reqBytes, err := json.Marshal(&req) 107 | if err != nil { 108 | return nil, err 109 | } 110 | 111 | var certPEM []byte 112 | if i.Auth != nil { 113 | certPEM, err = i.remote.AuthSign(reqBytes, nil, i.Auth) 114 | } else { 115 | certPEM, err = i.remote.Sign(reqBytes) 116 | } 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | caChainPEM := append(append(certPEM, '\n'), i.remoteCertPEM...) 122 | tlsCert, err := tls.X509KeyPair(caChainPEM, keyPEM) 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | // This can't error since it's called in tls.X509KeyPair above successfully 128 | tlsCert.Leaf, _ = x509.ParseCertificate(tlsCert.Certificate[0]) 129 | return &tlsCert, nil 130 | } 131 | -------------------------------------------------------------------------------- /issuers/cfssl/cfssl_suite_test.go: -------------------------------------------------------------------------------- 1 | package cfssl_test 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/tls" 9 | "crypto/x509" 10 | "crypto/x509/pkix" 11 | "encoding/json" 12 | "encoding/pem" 13 | "log" 14 | "math/big" 15 | "net" 16 | "net/url" 17 | "os" 18 | "testing" 19 | "time" 20 | 21 | "github.com/cloudflare/cfssl/api/client" 22 | "github.com/cloudflare/cfssl/config" 23 | . "github.com/onsi/ginkgo" 24 | . "github.com/onsi/gomega" 25 | "github.com/ory/dockertest/v3" 26 | "github.com/ory/dockertest/v3/docker" 27 | ) 28 | 29 | func TestCFSSL(t *testing.T) { 30 | RegisterFailHandler(Fail) 31 | RunSpecs(t, "CFSSL Suite") 32 | } 33 | 34 | type cfsslConfig struct { 35 | URL *url.URL 36 | CertPool *x509.CertPool 37 | AuthKey string 38 | Profile string 39 | } 40 | 41 | var ( 42 | pool *dockertest.Pool 43 | resources []*dockertest.Resource 44 | waiters []docker.CloseWaiter 45 | 46 | cfsslConf, cfsslTLSConf cfsslConfig 47 | ) 48 | 49 | var _ = BeforeSuite(func() { 50 | host := "localhost" 51 | if os.Getenv("DOCKER_HOST") != "" { 52 | u, err := url.Parse(os.Getenv("DOCKER_HOST")) 53 | Expect(err).To(Succeed()) 54 | host, _, err = net.SplitHostPort(u.Host) 55 | Expect(err).To(Succeed()) 56 | } 57 | 58 | log.SetOutput(GinkgoWriter) 59 | 60 | cert, key, err := generateCertAndKey(host, net.IPv4(0, 0, 0, 0), net.IPv6zero) 61 | Expect(err).To(Succeed()) 62 | 63 | pool, err = dockertest.NewPool("") 64 | Expect(err).To(Succeed()) 65 | 66 | pool.MaxWait = time.Second * 10 67 | 68 | By("Starting the CFSSL container", func() { 69 | cp := x509.NewCertPool() 70 | Expect(cp.AppendCertsFromPEM(cert)).To(BeTrue()) 71 | cfsslConf = cfsslConfig{ 72 | Profile: "authed", 73 | CertPool: cp, 74 | } 75 | repo := "cfssl/cfssl" 76 | version := "1.6.0" 77 | img := repo + ":" + version 78 | _, err = pool.Client.InspectImage(img) 79 | if err != nil { 80 | // Pull image 81 | Expect(pool.Client.PullImage(docker.PullImageOptions{ 82 | Repository: repo, 83 | Tag: version, 84 | OutputStream: GinkgoWriter, 85 | }, docker.AuthConfiguration{})).To(Succeed()) 86 | } 87 | 88 | c, err := pool.Client.CreateContainer(docker.CreateContainerOptions{ 89 | Name: "cfssl", 90 | Config: &docker.Config{ 91 | Image: img, 92 | ExposedPorts: map[docker.Port]struct{}{ 93 | docker.Port("8888"): {}, 94 | }, 95 | Cmd: []string{ 96 | "serve", 97 | "-loglevel", "0", 98 | "-address", "0.0.0.0", 99 | "-port", "8888", 100 | "-ca", "/cert.pem", 101 | "-ca-key", "/key.pem", 102 | "-config", "/conf.json", 103 | }, 104 | }, 105 | HostConfig: &docker.HostConfig{ 106 | PublishAllPorts: true, 107 | PortBindings: map[docker.Port][]docker.PortBinding{ 108 | "8888": {{HostPort: "8888"}}, 109 | }, 110 | }, 111 | }) 112 | Expect(err).To(Succeed()) 113 | 114 | b := &bytes.Buffer{} 115 | archive := tar.NewWriter(b) 116 | Expect(archive.WriteHeader(&tar.Header{ 117 | Name: "/cert.pem", 118 | Mode: 0o644, 119 | Size: int64(len(cert)), 120 | })).To(Succeed()) 121 | Expect(archive.Write(cert)).To(Equal(len(cert))) 122 | Expect(archive.WriteHeader(&tar.Header{ 123 | Name: "/key.pem", 124 | Mode: 0o644, 125 | Size: int64(len(key)), 126 | })).To(Succeed()) 127 | Expect(archive.Write(key)).To(Equal(len(key))) 128 | const authKey = "testKey" 129 | conf := config.Config{ 130 | Signing: &config.Signing{ 131 | Profiles: map[string]*config.SigningProfile{ 132 | cfsslConf.Profile: { 133 | AuthKeyName: authKey, 134 | Usage: []string{"signing", "key encipherment", "server auth", "client auth"}, 135 | Expiry: time.Hour * 8760, 136 | ExpiryString: "8760h", 137 | }, 138 | }, 139 | Default: config.DefaultConfig(), 140 | }, 141 | AuthKeys: map[string]config.AuthKey{ 142 | authKey: { 143 | Type: "standard", 144 | Key: "0123456789ABCDEF0123456789ABCDEF", 145 | }, 146 | }, 147 | } 148 | confBytes, err := json.Marshal(&conf) 149 | Expect(err).To(Succeed()) 150 | Expect(archive.WriteHeader(&tar.Header{ 151 | Name: "/conf.json", 152 | Mode: 0o644, 153 | Size: int64(len(confBytes)), 154 | })).To(Succeed()) 155 | Expect(archive.Write(confBytes)).To(Equal(len(confBytes))) 156 | Expect(archive.Close()).To(Succeed()) 157 | 158 | Expect(pool.Client.UploadToContainer(c.ID, docker.UploadToContainerOptions{ 159 | InputStream: b, 160 | Path: "/", 161 | })).To(Succeed()) 162 | 163 | Expect(pool.Client.StartContainer(c.ID, nil)).To(Succeed()) 164 | 165 | c, err = pool.Client.InspectContainer(c.ID) 166 | Expect(err).To(Succeed()) 167 | 168 | waiter, err := pool.Client.AttachToContainerNonBlocking(docker.AttachToContainerOptions{ 169 | Container: c.ID, 170 | OutputStream: GinkgoWriter, 171 | ErrorStream: GinkgoWriter, 172 | Stderr: true, 173 | Stdout: true, 174 | Stream: true, 175 | }) 176 | Expect(err).To(Succeed()) 177 | waiters = append(waiters, waiter) 178 | 179 | resources = append(resources, &dockertest.Resource{Container: c}) 180 | 181 | cfsslConf.URL = &url.URL{ 182 | Scheme: "http", 183 | Host: net.JoinHostPort(host, "8888"), 184 | } 185 | cfsslConf.AuthKey = conf.AuthKeys[authKey].Key 186 | 187 | remote := client.NewServerTLS(cfsslConf.URL.String(), &tls.Config{RootCAs: cp}) 188 | Expect(pool.Retry(func() error { 189 | _, err = remote.Info([]byte(`{}`)) 190 | return err 191 | })).To(Succeed()) 192 | }) 193 | 194 | By("Starting the CFSSL TLS container", func() { 195 | cp := x509.NewCertPool() 196 | Expect(cp.AppendCertsFromPEM(cert)).To(BeTrue()) 197 | cfsslTLSConf = cfsslConfig{ 198 | Profile: "authed", 199 | CertPool: cp, 200 | } 201 | repo := "cfssl/cfssl" 202 | version := "1.6.0" 203 | img := repo + ":" + version 204 | _, err = pool.Client.InspectImage(img) 205 | if err != nil { 206 | // Pull image 207 | Expect(pool.Client.PullImage(docker.PullImageOptions{ 208 | Repository: repo, 209 | Tag: version, 210 | OutputStream: GinkgoWriter, 211 | }, docker.AuthConfiguration{})).To(Succeed()) 212 | } 213 | 214 | c, err := pool.Client.CreateContainer(docker.CreateContainerOptions{ 215 | Name: "cfssl-tls", 216 | Config: &docker.Config{ 217 | Image: img, 218 | ExposedPorts: map[docker.Port]struct{}{ 219 | docker.Port("8889"): {}, 220 | }, 221 | Cmd: []string{ 222 | "serve", 223 | "-loglevel", "0", 224 | "-address", "0.0.0.0", 225 | "-port", "8889", 226 | "-ca", "/cert.pem", 227 | "-ca-key", "/key.pem", 228 | "-tls-cert", "/cert.pem", 229 | "-tls-key", "/key.pem", 230 | "-config", "/conf.json", 231 | }, 232 | }, 233 | HostConfig: &docker.HostConfig{ 234 | PublishAllPorts: true, 235 | PortBindings: map[docker.Port][]docker.PortBinding{ 236 | "8889": {{HostPort: "8889"}}, 237 | }, 238 | }, 239 | }) 240 | Expect(err).To(Succeed()) 241 | 242 | b := &bytes.Buffer{} 243 | archive := tar.NewWriter(b) 244 | Expect(archive.WriteHeader(&tar.Header{ 245 | Name: "/cert.pem", 246 | Mode: 0o644, 247 | Size: int64(len(cert)), 248 | })).To(Succeed()) 249 | Expect(archive.Write(cert)).To(Equal(len(cert))) 250 | Expect(archive.WriteHeader(&tar.Header{ 251 | Name: "/key.pem", 252 | Mode: 0o644, 253 | Size: int64(len(key)), 254 | })).To(Succeed()) 255 | Expect(archive.Write(key)).To(Equal(len(key))) 256 | const authKey = "testKey" 257 | conf := config.Config{ 258 | Signing: &config.Signing{ 259 | Profiles: map[string]*config.SigningProfile{ 260 | cfsslTLSConf.Profile: { 261 | AuthKeyName: authKey, 262 | Usage: []string{"signing", "key encipherment", "server auth", "client auth"}, 263 | Expiry: time.Hour * 8760, 264 | ExpiryString: "8760h", 265 | }, 266 | }, 267 | Default: config.DefaultConfig(), 268 | }, 269 | AuthKeys: map[string]config.AuthKey{ 270 | authKey: { 271 | Type: "standard", 272 | Key: "0123456789ABCDEF0123456789ABCDEF", 273 | }, 274 | }, 275 | } 276 | confBytes, err := json.Marshal(&conf) 277 | Expect(err).To(Succeed()) 278 | Expect(archive.WriteHeader(&tar.Header{ 279 | Name: "/conf.json", 280 | Mode: 0o644, 281 | Size: int64(len(confBytes)), 282 | })).To(Succeed()) 283 | Expect(archive.Write(confBytes)).To(Equal(len(confBytes))) 284 | Expect(archive.Close()).To(Succeed()) 285 | 286 | Expect(pool.Client.UploadToContainer(c.ID, docker.UploadToContainerOptions{ 287 | InputStream: b, 288 | Path: "/", 289 | })).To(Succeed()) 290 | 291 | Expect(pool.Client.StartContainer(c.ID, nil)).To(Succeed()) 292 | 293 | c, err = pool.Client.InspectContainer(c.ID) 294 | Expect(err).To(Succeed()) 295 | 296 | waiter, err := pool.Client.AttachToContainerNonBlocking(docker.AttachToContainerOptions{ 297 | Container: c.ID, 298 | OutputStream: GinkgoWriter, 299 | ErrorStream: GinkgoWriter, 300 | Stderr: true, 301 | Stdout: true, 302 | Stream: true, 303 | }) 304 | waiters = append(waiters, waiter) 305 | 306 | resources = append(resources, &dockertest.Resource{Container: c}) 307 | 308 | cfsslTLSConf.URL = &url.URL{ 309 | Scheme: "https", 310 | Host: net.JoinHostPort(host, "8889"), 311 | } 312 | cfsslTLSConf.AuthKey = conf.AuthKeys[authKey].Key 313 | 314 | remote := client.NewServerTLS(cfsslTLSConf.URL.String(), &tls.Config{RootCAs: cp}) 315 | Expect(pool.Retry(func() error { 316 | _, err = remote.Info([]byte(`{}`)) 317 | return err 318 | })).To(Succeed()) 319 | }) 320 | }) 321 | 322 | var _ = AfterSuite(func() { 323 | for _, waiter := range waiters { 324 | Expect(waiter.Close()).To(Succeed()) 325 | Expect(waiter.Wait()).To(Succeed()) 326 | } 327 | for _, resource := range resources { 328 | Expect(pool.Purge(resource)).To(Succeed()) 329 | } 330 | }) 331 | 332 | func generateCertAndKey(SAN string, IPSAN ...net.IP) ([]byte, []byte, error) { 333 | priv, err := rsa.GenerateKey(rand.Reader, 2048) 334 | if err != nil { 335 | return nil, nil, err 336 | } 337 | notBefore := time.Now() 338 | notAfter := notBefore.Add(time.Hour) 339 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 340 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 341 | if err != nil { 342 | return nil, nil, err 343 | } 344 | template := x509.Certificate{ 345 | SerialNumber: serialNumber, 346 | Subject: pkix.Name{ 347 | CommonName: "Certify Test Cert", 348 | }, 349 | NotBefore: notBefore, 350 | NotAfter: notAfter, 351 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 352 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 353 | BasicConstraintsValid: true, 354 | DNSNames: []string{SAN}, 355 | IPAddresses: IPSAN, 356 | } 357 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, priv.Public(), priv) 358 | if err != nil { 359 | return nil, nil, err 360 | } 361 | certOut := pem.EncodeToMemory(&pem.Block{ 362 | Type: "CERTIFICATE", 363 | Bytes: derBytes, 364 | }) 365 | keyOut := pem.EncodeToMemory(&pem.Block{ 366 | Type: "RSA PRIVATE KEY", 367 | Bytes: x509.MarshalPKCS1PrivateKey(priv), 368 | }) 369 | 370 | return certOut, keyOut, nil 371 | } 372 | -------------------------------------------------------------------------------- /issuers/cfssl/cfssl_test.go: -------------------------------------------------------------------------------- 1 | package cfssl_test 2 | 3 | import ( 4 | "context" 5 | "crypto" 6 | "crypto/ecdsa" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/tls" 10 | "crypto/x509" 11 | "net" 12 | 13 | "github.com/cloudflare/cfssl/api/client" 14 | "github.com/cloudflare/cfssl/auth" 15 | . "github.com/onsi/ginkgo" 16 | . "github.com/onsi/gomega" 17 | 18 | "github.com/johanbrandhorst/certify" 19 | "github.com/johanbrandhorst/certify/issuers/cfssl" 20 | ) 21 | 22 | var _ = Describe("CFSSL Issuer", func() { 23 | var iss certify.Issuer 24 | var conf *certify.CertConfig 25 | 26 | BeforeEach(func() { 27 | iss = &cfssl.Issuer{ 28 | URL: cfsslConf.URL, 29 | } 30 | conf = &certify.CertConfig{ 31 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 32 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 33 | }), 34 | } 35 | }) 36 | 37 | It("issues a certificate", func() { 38 | cn := "somename.com" 39 | 40 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 41 | Expect(err).NotTo(HaveOccurred()) 42 | 43 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 44 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 45 | 46 | // Check that chain is included 47 | Expect(tlsCert.Certificate).To(HaveLen(2)) 48 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 49 | Expect(err).NotTo(HaveOccurred()) 50 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 51 | }) 52 | 53 | Context("when specifying some SANs, IPSANs", func() { 54 | It("issues a certificate with the SANs and IPSANs", func() { 55 | conf := &certify.CertConfig{ 56 | SubjectAlternativeNames: []string{"extraname.com", "otherextraname.com"}, 57 | IPSubjectAlternativeNames: []net.IP{net.IPv4(1, 2, 3, 4), net.IPv6loopback}, 58 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 59 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 60 | }), 61 | } 62 | cn := "somename.com" 63 | 64 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 65 | Expect(err).NotTo(HaveOccurred()) 66 | 67 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 68 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 69 | Expect(tlsCert.Leaf.DNSNames).To(Equal(conf.SubjectAlternativeNames)) 70 | Expect(tlsCert.Leaf.IPAddresses).To(HaveLen(len(conf.IPSubjectAlternativeNames))) 71 | for i, ip := range tlsCert.Leaf.IPAddresses { 72 | Expect(ip.Equal(conf.IPSubjectAlternativeNames[i])).To(BeTrue()) 73 | } 74 | 75 | // Check that chain is included 76 | Expect(tlsCert.Certificate).To(HaveLen(2)) 77 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 78 | Expect(err).NotTo(HaveOccurred()) 79 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 80 | }) 81 | }) 82 | }) 83 | 84 | var _ = Describe("Authenticated CFSSL Issuer", func() { 85 | var iss certify.Issuer 86 | var conf *certify.CertConfig 87 | 88 | BeforeEach(func() { 89 | st, err := auth.New(cfsslConf.AuthKey, nil) 90 | Expect(err).To(Succeed()) 91 | iss = &cfssl.Issuer{ 92 | URL: cfsslConf.URL, 93 | Auth: st, 94 | Profile: cfsslConf.Profile, 95 | } 96 | conf = &certify.CertConfig{ 97 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 98 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 99 | }), 100 | } 101 | }) 102 | 103 | It("issues a certificate", func() { 104 | cn := "somename.com" 105 | 106 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 107 | Expect(err).NotTo(HaveOccurred()) 108 | 109 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 110 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 111 | 112 | // Check that chain is included 113 | Expect(tlsCert.Certificate).To(HaveLen(2)) 114 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 115 | Expect(err).NotTo(HaveOccurred()) 116 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 117 | }) 118 | 119 | Context("when specifying some SANs, IPSANs", func() { 120 | It("issues a certificate with the SANs and IPSANs", func() { 121 | conf.SubjectAlternativeNames = []string{"extraname.com", "otherextraname.com"} 122 | conf.IPSubjectAlternativeNames = []net.IP{net.IPv4(1, 2, 3, 4), net.IPv6loopback} 123 | cn := "somename.com" 124 | 125 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 126 | Expect(err).NotTo(HaveOccurred()) 127 | 128 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 129 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 130 | Expect(tlsCert.Leaf.DNSNames).To(Equal(conf.SubjectAlternativeNames)) 131 | Expect(tlsCert.Leaf.IPAddresses).To(HaveLen(len(conf.IPSubjectAlternativeNames))) 132 | for i, ip := range tlsCert.Leaf.IPAddresses { 133 | Expect(ip.Equal(conf.IPSubjectAlternativeNames[i])).To(BeTrue()) 134 | } 135 | 136 | // Check that chain is included 137 | Expect(tlsCert.Certificate).To(HaveLen(2)) 138 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 139 | Expect(err).NotTo(HaveOccurred()) 140 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 141 | }) 142 | }) 143 | }) 144 | 145 | var _ = Describe("CFSSL TLS Issuer", func() { 146 | var iss certify.Issuer 147 | var conf *certify.CertConfig 148 | 149 | BeforeEach(func() { 150 | iss = &cfssl.Issuer{ 151 | URL: cfsslTLSConf.URL, 152 | TLSConfig: &tls.Config{ 153 | RootCAs: cfsslTLSConf.CertPool, 154 | }, 155 | } 156 | conf = &certify.CertConfig{ 157 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 158 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 159 | }), 160 | } 161 | }) 162 | 163 | It("issues a certificate", func() { 164 | cn := "somename.com" 165 | 166 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 167 | Expect(err).NotTo(HaveOccurred()) 168 | 169 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 170 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 171 | 172 | // Check that chain is included 173 | Expect(tlsCert.Certificate).To(HaveLen(2)) 174 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 175 | Expect(err).NotTo(HaveOccurred()) 176 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 177 | }) 178 | 179 | Context("when specifying some SANs, IPSANs", func() { 180 | It("issues a certificate with the SANs and IPSANs", func() { 181 | conf.SubjectAlternativeNames = []string{"extraname.com", "otherextraname.com"} 182 | conf.IPSubjectAlternativeNames = []net.IP{net.IPv4(1, 2, 3, 4), net.IPv6loopback} 183 | cn := "somename.com" 184 | 185 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 186 | Expect(err).NotTo(HaveOccurred()) 187 | 188 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 189 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 190 | Expect(tlsCert.Leaf.DNSNames).To(Equal(conf.SubjectAlternativeNames)) 191 | Expect(tlsCert.Leaf.IPAddresses).To(HaveLen(len(conf.IPSubjectAlternativeNames))) 192 | for i, ip := range tlsCert.Leaf.IPAddresses { 193 | Expect(ip.Equal(conf.IPSubjectAlternativeNames[i])).To(BeTrue()) 194 | } 195 | 196 | // Check that chain is included 197 | Expect(tlsCert.Certificate).To(HaveLen(2)) 198 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 199 | Expect(err).NotTo(HaveOccurred()) 200 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 201 | }) 202 | }) 203 | }) 204 | 205 | var _ = Describe("Authenticated CFSSL Issuer", func() { 206 | var iss certify.Issuer 207 | var conf *certify.CertConfig 208 | 209 | BeforeEach(func() { 210 | st, err := auth.New(cfsslConf.AuthKey, nil) 211 | Expect(err).To(Succeed()) 212 | iss = &cfssl.Issuer{ 213 | URL: cfsslTLSConf.URL, 214 | TLSConfig: &tls.Config{ 215 | RootCAs: cfsslTLSConf.CertPool, 216 | }, 217 | Auth: st, 218 | Profile: cfsslTLSConf.Profile, 219 | } 220 | conf = &certify.CertConfig{ 221 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 222 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 223 | }), 224 | } 225 | }) 226 | 227 | It("issues a certificate", func() { 228 | cn := "somename.com" 229 | 230 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 231 | Expect(err).NotTo(HaveOccurred()) 232 | 233 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 234 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 235 | 236 | // Check that chain is included 237 | Expect(tlsCert.Certificate).To(HaveLen(2)) 238 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 239 | Expect(err).NotTo(HaveOccurred()) 240 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 241 | }) 242 | 243 | Context("when specifying some SANs, IPSANs", func() { 244 | It("issues a certificate with the SANs and IPSANs", func() { 245 | conf.SubjectAlternativeNames = []string{"extraname.com", "otherextraname.com"} 246 | conf.IPSubjectAlternativeNames = []net.IP{net.IPv4(1, 2, 3, 4), net.IPv6loopback} 247 | cn := "somename.com" 248 | 249 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 250 | Expect(err).NotTo(HaveOccurred()) 251 | 252 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 253 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 254 | Expect(tlsCert.Leaf.DNSNames).To(Equal(conf.SubjectAlternativeNames)) 255 | Expect(tlsCert.Leaf.IPAddresses).To(HaveLen(len(conf.IPSubjectAlternativeNames))) 256 | for i, ip := range tlsCert.Leaf.IPAddresses { 257 | Expect(ip.Equal(conf.IPSubjectAlternativeNames[i])).To(BeTrue()) 258 | } 259 | 260 | // Check that chain is included 261 | Expect(tlsCert.Certificate).To(HaveLen(2)) 262 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 263 | Expect(err).NotTo(HaveOccurred()) 264 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 265 | }) 266 | }) 267 | }) 268 | 269 | var _ = Describe("Using a pre-created client", func() { 270 | It("issues a certificate", func() { 271 | remote := client.NewServer(cfsslConf.URL.String()) 272 | iss, err := cfssl.FromClient(remote) 273 | Expect(err).To(Succeed()) 274 | 275 | cn := "somename.com" 276 | conf := &certify.CertConfig{ 277 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 278 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 279 | }), 280 | } 281 | 282 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 283 | Expect(err).NotTo(HaveOccurred()) 284 | 285 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 286 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 287 | 288 | // Check that chain is included 289 | Expect(tlsCert.Certificate).To(HaveLen(2)) 290 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 291 | Expect(err).NotTo(HaveOccurred()) 292 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 293 | }) 294 | }) 295 | 296 | type keyGeneratorFunc func() (crypto.PrivateKey, error) 297 | 298 | func (kgf keyGeneratorFunc) Generate() (crypto.PrivateKey, error) { 299 | return kgf() 300 | } 301 | -------------------------------------------------------------------------------- /issuers/vault/proto/test.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.28.0 4 | // protoc (unknown) 5 | // source: issuers/vault/proto/test.proto 6 | 7 | package proto 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | reflect "reflect" 13 | sync "sync" 14 | ) 15 | 16 | const ( 17 | // Verify that this generated code is sufficiently up-to-date. 18 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 19 | // Verify that runtime/protoimpl is sufficiently up-to-date. 20 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 21 | ) 22 | 23 | type Void struct { 24 | state protoimpl.MessageState 25 | sizeCache protoimpl.SizeCache 26 | unknownFields protoimpl.UnknownFields 27 | } 28 | 29 | func (x *Void) Reset() { 30 | *x = Void{} 31 | if protoimpl.UnsafeEnabled { 32 | mi := &file_issuers_vault_proto_test_proto_msgTypes[0] 33 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 34 | ms.StoreMessageInfo(mi) 35 | } 36 | } 37 | 38 | func (x *Void) String() string { 39 | return protoimpl.X.MessageStringOf(x) 40 | } 41 | 42 | func (*Void) ProtoMessage() {} 43 | 44 | func (x *Void) ProtoReflect() protoreflect.Message { 45 | mi := &file_issuers_vault_proto_test_proto_msgTypes[0] 46 | if protoimpl.UnsafeEnabled && x != nil { 47 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 48 | if ms.LoadMessageInfo() == nil { 49 | ms.StoreMessageInfo(mi) 50 | } 51 | return ms 52 | } 53 | return mi.MessageOf(x) 54 | } 55 | 56 | // Deprecated: Use Void.ProtoReflect.Descriptor instead. 57 | func (*Void) Descriptor() ([]byte, []int) { 58 | return file_issuers_vault_proto_test_proto_rawDescGZIP(), []int{0} 59 | } 60 | 61 | var File_issuers_vault_proto_test_proto protoreflect.FileDescriptor 62 | 63 | var file_issuers_vault_proto_test_proto_rawDesc = []byte{ 64 | 0x0a, 0x1e, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x73, 0x2f, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x2f, 65 | 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 66 | 0x12, 0x05, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x06, 0x0a, 0x04, 0x56, 0x6f, 0x69, 0x64, 0x32, 67 | 0x2a, 0x0a, 0x04, 0x54, 0x65, 0x73, 0x74, 0x12, 0x22, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, 0x12, 68 | 0x0b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x56, 0x6f, 0x69, 0x64, 0x1a, 0x0b, 0x2e, 0x70, 69 | 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x56, 0x6f, 0x69, 0x64, 0x22, 0x00, 0x42, 0x38, 0x5a, 0x36, 0x67, 70 | 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6a, 0x6f, 0x68, 0x61, 0x6e, 0x62, 71 | 0x72, 0x61, 0x6e, 0x64, 0x68, 0x6f, 0x72, 0x73, 0x74, 0x2f, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 72 | 0x79, 0x2f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x72, 0x73, 0x2f, 0x76, 0x61, 0x75, 0x6c, 0x74, 0x2f, 73 | 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, 74 | } 75 | 76 | var ( 77 | file_issuers_vault_proto_test_proto_rawDescOnce sync.Once 78 | file_issuers_vault_proto_test_proto_rawDescData = file_issuers_vault_proto_test_proto_rawDesc 79 | ) 80 | 81 | func file_issuers_vault_proto_test_proto_rawDescGZIP() []byte { 82 | file_issuers_vault_proto_test_proto_rawDescOnce.Do(func() { 83 | file_issuers_vault_proto_test_proto_rawDescData = protoimpl.X.CompressGZIP(file_issuers_vault_proto_test_proto_rawDescData) 84 | }) 85 | return file_issuers_vault_proto_test_proto_rawDescData 86 | } 87 | 88 | var file_issuers_vault_proto_test_proto_msgTypes = make([]protoimpl.MessageInfo, 1) 89 | var file_issuers_vault_proto_test_proto_goTypes = []interface{}{ 90 | (*Void)(nil), // 0: proto.Void 91 | } 92 | var file_issuers_vault_proto_test_proto_depIdxs = []int32{ 93 | 0, // 0: proto.Test.Ping:input_type -> proto.Void 94 | 0, // 1: proto.Test.Ping:output_type -> proto.Void 95 | 1, // [1:2] is the sub-list for method output_type 96 | 0, // [0:1] is the sub-list for method input_type 97 | 0, // [0:0] is the sub-list for extension type_name 98 | 0, // [0:0] is the sub-list for extension extendee 99 | 0, // [0:0] is the sub-list for field type_name 100 | } 101 | 102 | func init() { file_issuers_vault_proto_test_proto_init() } 103 | func file_issuers_vault_proto_test_proto_init() { 104 | if File_issuers_vault_proto_test_proto != nil { 105 | return 106 | } 107 | if !protoimpl.UnsafeEnabled { 108 | file_issuers_vault_proto_test_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { 109 | switch v := v.(*Void); i { 110 | case 0: 111 | return &v.state 112 | case 1: 113 | return &v.sizeCache 114 | case 2: 115 | return &v.unknownFields 116 | default: 117 | return nil 118 | } 119 | } 120 | } 121 | type x struct{} 122 | out := protoimpl.TypeBuilder{ 123 | File: protoimpl.DescBuilder{ 124 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 125 | RawDescriptor: file_issuers_vault_proto_test_proto_rawDesc, 126 | NumEnums: 0, 127 | NumMessages: 1, 128 | NumExtensions: 0, 129 | NumServices: 1, 130 | }, 131 | GoTypes: file_issuers_vault_proto_test_proto_goTypes, 132 | DependencyIndexes: file_issuers_vault_proto_test_proto_depIdxs, 133 | MessageInfos: file_issuers_vault_proto_test_proto_msgTypes, 134 | }.Build() 135 | File_issuers_vault_proto_test_proto = out.File 136 | file_issuers_vault_proto_test_proto_rawDesc = nil 137 | file_issuers_vault_proto_test_proto_goTypes = nil 138 | file_issuers_vault_proto_test_proto_depIdxs = nil 139 | } 140 | -------------------------------------------------------------------------------- /issuers/vault/proto/test.proto: -------------------------------------------------------------------------------- 1 | syntax="proto3"; 2 | 3 | option go_package = "github.com/johanbrandhorst/certify/issuers/vault/proto"; 4 | 5 | package proto; 6 | 7 | service Test { 8 | rpc Ping(Void) returns (Void) {} 9 | } 10 | 11 | message Void {} 12 | -------------------------------------------------------------------------------- /issuers/vault/proto/test_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 2 | 3 | package proto 4 | 5 | import ( 6 | context "context" 7 | grpc "google.golang.org/grpc" 8 | codes "google.golang.org/grpc/codes" 9 | status "google.golang.org/grpc/status" 10 | ) 11 | 12 | // This is a compile-time assertion to ensure that this generated file 13 | // is compatible with the grpc package it is being compiled against. 14 | // Requires gRPC-Go v1.32.0 or later. 15 | const _ = grpc.SupportPackageIsVersion7 16 | 17 | // TestClient is the client API for Test service. 18 | // 19 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 20 | type TestClient interface { 21 | Ping(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Void, error) 22 | } 23 | 24 | type testClient struct { 25 | cc grpc.ClientConnInterface 26 | } 27 | 28 | func NewTestClient(cc grpc.ClientConnInterface) TestClient { 29 | return &testClient{cc} 30 | } 31 | 32 | func (c *testClient) Ping(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Void, error) { 33 | out := new(Void) 34 | err := c.cc.Invoke(ctx, "/proto.Test/Ping", in, out, opts...) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return out, nil 39 | } 40 | 41 | // TestServer is the server API for Test service. 42 | // All implementations should embed UnimplementedTestServer 43 | // for forward compatibility 44 | type TestServer interface { 45 | Ping(context.Context, *Void) (*Void, error) 46 | } 47 | 48 | // UnimplementedTestServer should be embedded to have forward compatible implementations. 49 | type UnimplementedTestServer struct { 50 | } 51 | 52 | func (UnimplementedTestServer) Ping(context.Context, *Void) (*Void, error) { 53 | return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented") 54 | } 55 | 56 | // UnsafeTestServer may be embedded to opt out of forward compatibility for this service. 57 | // Use of this interface is not recommended, as added methods to TestServer will 58 | // result in compilation errors. 59 | type UnsafeTestServer interface { 60 | mustEmbedUnimplementedTestServer() 61 | } 62 | 63 | func RegisterTestServer(s grpc.ServiceRegistrar, srv TestServer) { 64 | s.RegisterService(&Test_ServiceDesc, srv) 65 | } 66 | 67 | func _Test_Ping_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 68 | in := new(Void) 69 | if err := dec(in); err != nil { 70 | return nil, err 71 | } 72 | if interceptor == nil { 73 | return srv.(TestServer).Ping(ctx, in) 74 | } 75 | info := &grpc.UnaryServerInfo{ 76 | Server: srv, 77 | FullMethod: "/proto.Test/Ping", 78 | } 79 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 80 | return srv.(TestServer).Ping(ctx, req.(*Void)) 81 | } 82 | return interceptor(ctx, in, info, handler) 83 | } 84 | 85 | // Test_ServiceDesc is the grpc.ServiceDesc for Test service. 86 | // It's only intended for direct use with grpc.RegisterService, 87 | // and not to be introspected or modified (even as a copy) 88 | var Test_ServiceDesc = grpc.ServiceDesc{ 89 | ServiceName: "proto.Test", 90 | HandlerType: (*TestServer)(nil), 91 | Methods: []grpc.MethodDesc{ 92 | { 93 | MethodName: "Ping", 94 | Handler: _Test_Ping_Handler, 95 | }, 96 | }, 97 | Streams: []grpc.StreamDesc{}, 98 | Metadata: "issuers/vault/proto/test.proto", 99 | } 100 | -------------------------------------------------------------------------------- /issuers/vault/types.go: -------------------------------------------------------------------------------- 1 | package vault 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/hashicorp/vault/api" 11 | ) 12 | 13 | // AuthMethod defines the interface required to implement 14 | // custom authentication against the Vault server. 15 | type AuthMethod interface { 16 | SetToken(context.Context, *api.Client) error 17 | } 18 | 19 | // ConstantToken implements AuthMethod with a constant token 20 | type ConstantToken string 21 | 22 | // SetToken sets the clients token to the constant token value. 23 | func (c ConstantToken) SetToken(_ context.Context, cli *api.Client) error { 24 | cli.SetToken(string(c)) 25 | return nil 26 | } 27 | 28 | // RenewingToken is used for automatically renewing 29 | // the token used to authenticate with Vault. RenewingToken 30 | // requires SetToken to be called at least once before the 31 | // expiry of the initial token. 32 | type RenewingToken struct { 33 | // Initial is the token used to initially 34 | // authenticate against Vault. It must be 35 | // renewable. 36 | Initial string 37 | // RenewBefore configures how long before the expiry 38 | // of the token it should be renewed. Defaults 39 | // to 30 minutes before expiry. 40 | RenewBefore time.Duration 41 | // TimeToLive configures how long the new token 42 | // should be valid for. Defaults to 24 hours. 43 | TimeToLive time.Duration 44 | 45 | o sync.Once 46 | 47 | token string 48 | tokenMu sync.Mutex 49 | errC chan error 50 | cancel func() 51 | } 52 | 53 | // SetToken implements AuthMethod for RenewingToken. 54 | func (r *RenewingToken) SetToken(ctx context.Context, cli *api.Client) error { 55 | var err error 56 | r.o.Do(func() { 57 | cli.SetToken(string(r.Initial)) 58 | r.token = r.Initial 59 | if r.RenewBefore <= 0 { 60 | r.RenewBefore = 30 * time.Minute 61 | } 62 | if r.TimeToLive <= 0 { 63 | r.TimeToLive = 24 * time.Hour 64 | } 65 | r.errC = make(chan error) 66 | 67 | req := cli.NewRequest("GET", "/v1/auth/token/lookup-self") 68 | resp, tErr := cli.RawRequestWithContext(ctx, req) 69 | if tErr != nil { 70 | err = tErr 71 | return 72 | } 73 | defer resp.Body.Close() 74 | 75 | tok, tErr := api.ParseSecret(resp.Body) 76 | if tErr != nil { 77 | err = tErr 78 | return 79 | } 80 | 81 | rn, tErr := tok.TokenIsRenewable() 82 | if tErr != nil { 83 | err = tErr 84 | return 85 | } 86 | 87 | if !rn { 88 | err = fmt.Errorf("token was not renewable") 89 | return 90 | } 91 | 92 | ttl, tErr := tok.TokenTTL() 93 | if tErr != nil { 94 | err = tErr 95 | return 96 | } 97 | 98 | // Start background process for renewing the token 99 | var cctx context.Context 100 | cctx, r.cancel = context.WithCancel(context.Background()) 101 | go func() { 102 | for { 103 | wait := ttl - r.RenewBefore 104 | if wait < time.Second { 105 | // Wait for at least one second, in case we somehow end up 106 | // with a very short wait. 107 | wait = time.Second 108 | } 109 | 110 | tk := time.NewTicker(wait) 111 | 112 | select { 113 | case <-cctx.Done(): 114 | return 115 | case <-tk.C: 116 | tk.Stop() 117 | } 118 | 119 | // Needs renewal 120 | req := cli.NewRequest("PUT", "/v1/auth/token/renew-self") 121 | 122 | body := map[string]interface{}{"increment": r.TimeToLive.Seconds()} 123 | if err := req.SetJSONBody(body); err != nil { 124 | r.errC <- err 125 | return 126 | } 127 | resp, tErr := cli.RawRequestWithContext(cctx, req) 128 | if tErr != nil { 129 | r.errC <- err 130 | return 131 | } 132 | defer resp.Body.Close() 133 | 134 | tok, tErr := api.ParseSecret(resp.Body) 135 | if tErr != nil { 136 | r.errC <- err 137 | return 138 | } 139 | if err != nil { 140 | r.errC <- err 141 | return 142 | } 143 | 144 | r.tokenMu.Lock() 145 | r.token = tok.Auth.ClientToken 146 | r.tokenMu.Unlock() 147 | ttl = r.TimeToLive 148 | } 149 | }() 150 | }) 151 | if err != nil { 152 | return err 153 | } 154 | 155 | select { 156 | case err = <-r.errC: 157 | return err 158 | default: 159 | } 160 | 161 | r.tokenMu.Lock() 162 | tok := r.token 163 | r.tokenMu.Unlock() 164 | cli.SetToken(tok) 165 | 166 | return nil 167 | } 168 | 169 | // Close can be used to release resources associated with the token. 170 | func (r *RenewingToken) Close() error { 171 | r.cancel() 172 | return nil 173 | } 174 | 175 | // https://www.vaultproject.io/api/secret/pki/index.html#parameters-14 176 | type csrOpts struct { 177 | CSR string `json:"csr"` 178 | CommonName string `json:"common_name"` 179 | ExcludeCNFromSANS bool `json:"exclude_cn_from_sans"` 180 | Format string `json:"format"` 181 | AltNames otherSans `json:"alt_names,omitempty"` 182 | IPSans otherSans `json:"ip_sans,omitempty"` 183 | URISans otherSans `json:"uri_sans,omitempty"` 184 | OtherSans otherSans `json:"other_sans,omitempty"` 185 | TimeToLive ttl `json:"ttl,omitempty"` 186 | } 187 | 188 | type otherSans []string 189 | 190 | func (o otherSans) MarshalJSON() ([]byte, error) { 191 | return []byte(`"` + strings.Join(o, ",") + `"`), nil 192 | } 193 | 194 | type ttl time.Duration 195 | 196 | func (t ttl) MarshalJSON() ([]byte, error) { 197 | return []byte(`"` + time.Duration(t).String() + `"`), nil 198 | } 199 | -------------------------------------------------------------------------------- /issuers/vault/vault.go: -------------------------------------------------------------------------------- 1 | package vault 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "crypto/x509" 7 | "errors" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "time" 12 | 13 | "github.com/hashicorp/vault/api" 14 | 15 | "github.com/johanbrandhorst/certify" 16 | "github.com/johanbrandhorst/certify/internal/csr" 17 | ) 18 | 19 | // Issuer implements the Issuer interface with a 20 | // Hashicorp Vault PKI Secrets Engine backend. 21 | // 22 | // URL, Role and AuthMethod are required. 23 | type Issuer struct { 24 | // URL is the URL of the Vault instance. 25 | URL *url.URL 26 | // Role is the Vault Role that should be used 27 | // when issuing certificates. 28 | Role string 29 | 30 | // Token is the Vault secret token that should be used 31 | // when issuing certificates. 32 | // 33 | // Deprecated: use AuthMethod instead. 34 | Token string 35 | // AuthMethod configures the method used for authenticating 36 | // against the Vault server. 37 | AuthMethod AuthMethod 38 | 39 | // Mount is the name under which the PKI secrets engine 40 | // is mounted. Defaults to `pki` 41 | Mount string 42 | // TLSConfig allows configuration of the TLS config 43 | // used when connecting to the Vault server. 44 | TLSConfig *tls.Config 45 | 46 | // TimeToLive configures the lifetime of certificates 47 | // requested from the Vault server. 48 | TimeToLive time.Duration 49 | 50 | // SubjectAlternativeNames defines additional DNS or Email Subject Alternative Names 51 | // 52 | // Warning: By default Vault reads SANs directly from the 53 | // Certificate Signing Request (CSR), and ignores this field completely. 54 | // This field only takes effect when the Vault role has set use_csr_sans to false, 55 | // and using this setting will ignore any SANs in the CSR. 56 | // 57 | // To configure DNS SANs directly in the CSR, set CertConfig.SubjectAlternativeNames, 58 | SubjectAlternativeNames []string 59 | 60 | // IPSubjectAlternativeNames defines additional IP Address Subject Alternative Names 61 | // 62 | // Warning: By default Vault reads IP SANs directly from the 63 | // Certificate Signing Request (CSR), and ignores this field completely. 64 | // This field only takes effect when the Vault role has set use_csr_sans to false, 65 | // and using this setting will ignore any SANs in the CSR. 66 | // 67 | // To configure IP SANs directly in the CSR, set CertConfig.IPSubjectAlternativeNames, 68 | IPSubjectAlternativeNames []string 69 | 70 | // URISubjectAlternativeNames defines custom URI SANs. 71 | // The format is a URI and must match the value specified in allowed_uri_sans, eg spiffe://hostname/foobar 72 | // 73 | // Warning: By default Vault reads URI SANs directly from the 74 | // Certificate Signing Request (CSR), and ignores this field completely. 75 | // This field only takes effect when the Vault role has set use_csr_sans to false, 76 | // and using this setting will ignore any SANs in the CSR. 77 | // 78 | // To configure URI SANs directly in the CSR, set CertConfig.URISubjectAlternativeNames, 79 | URISubjectAlternativeNames []string 80 | 81 | // OtherSubjectAlternativeNames defines custom OID/UTF8-string SANs. 82 | // The format is the same as OpenSSL: ;: where the only current valid is UTF8. 83 | // 84 | // Warning: By default Vault reads SANs directly from the 85 | // Certificate Signing Request (CSR), and ignores this field completely. 86 | // This field only takes effect when the Vault role has set use_csr_sans to false, 87 | // and using this setting will ignore any SANs in the CSR. 88 | OtherSubjectAlternativeNames []string 89 | 90 | cli *api.Client 91 | } 92 | 93 | // FromClient returns an Issuer using the provided Vault API client. 94 | // Any changes to the issuers properties (such as setting the TTL or adding Other SANS) 95 | // must be done before using it. The Issuer will default to using 96 | // the token already defined in the client for authentication. 97 | func FromClient(v *api.Client, role string) *Issuer { 98 | return &Issuer{ 99 | Role: role, 100 | AuthMethod: ConstantToken(v.Token()), 101 | cli: v, 102 | } 103 | } 104 | 105 | func (v *Issuer) connect(ctx context.Context) error { 106 | vConf := api.DefaultConfig() 107 | 108 | if v.TLSConfig != nil { 109 | vConf.HttpClient.Transport.(*http.Transport).TLSClientConfig = v.TLSConfig.Clone() 110 | } 111 | 112 | vConf.Address = v.URL.String() 113 | var err error 114 | v.cli, err = api.NewClient(vConf) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | return nil 120 | } 121 | 122 | // Issue issues a certificate from the configured Vault backend, 123 | // establishing a connection if one doesn't already exist. 124 | func (v *Issuer) Issue(ctx context.Context, commonName string, conf *certify.CertConfig) (*tls.Certificate, error) { 125 | if v.cli == nil { // Could be set by FromClient 126 | err := v.connect(ctx) 127 | if err != nil { 128 | return nil, err 129 | } 130 | } 131 | 132 | // Convert Token to AuthMethod, if no AuthMethod set, 133 | // for backwards compatibility. 134 | if v.AuthMethod == nil { 135 | v.AuthMethod = ConstantToken(v.Token) 136 | } 137 | 138 | csrPEM, keyPEM, err := csr.FromCertConfig(commonName, conf) 139 | if err != nil { 140 | return nil, err 141 | } 142 | 143 | opts := csrOpts{ 144 | CSR: string(csrPEM), 145 | CommonName: commonName, 146 | ExcludeCNFromSANS: true, 147 | Format: "pem", 148 | AltNames: v.SubjectAlternativeNames, 149 | IPSans: v.IPSubjectAlternativeNames, 150 | URISans: v.URISubjectAlternativeNames, 151 | OtherSans: v.OtherSubjectAlternativeNames, 152 | TimeToLive: ttl(v.TimeToLive), 153 | } 154 | 155 | secret, err := v.signCSR(ctx, opts) 156 | if err != nil { 157 | return nil, err 158 | } 159 | if secret == nil { 160 | // This can happen if the Vault server is sealed or 161 | // there are temporary connection issues. 162 | return nil, errors.New("no secret returned from Vault, please try again") 163 | } 164 | 165 | // https://www.vaultproject.io/api/secret/pki/index.html#sample-response-15 166 | certPEM := []byte(secret.Data["certificate"].(string)) 167 | caChainPEM := certPEM 168 | if caChain, ok := secret.Data["ca_chain"]; ok { 169 | for _, pemData := range caChain.([]interface{}) { 170 | caChainPEM = append(append(caChainPEM, '\n'), []byte(pemData.(string))...) 171 | } 172 | } else if ca, ok := secret.Data["issuing_ca"]; ok { 173 | caChainPEM = append(append(caChainPEM, '\n'), []byte(ca.(string))...) 174 | } 175 | 176 | tlsCert, err := tls.X509KeyPair(caChainPEM, keyPEM) 177 | if err != nil { 178 | return nil, err 179 | } 180 | 181 | // This can't error since it's called in tls.X509KeyPair above successfully 182 | tlsCert.Leaf, _ = x509.ParseCertificate(tlsCert.Certificate[0]) 183 | return &tlsCert, nil 184 | } 185 | 186 | func (v Issuer) signCSR(ctx context.Context, opts csrOpts) (*api.Secret, error) { 187 | pkiMountName := "pki" 188 | if v.Mount != "" { 189 | pkiMountName = v.Mount 190 | } 191 | 192 | // Update token immediately before making the request 193 | err := v.AuthMethod.SetToken(ctx, v.cli) 194 | if err != nil { 195 | return nil, err 196 | } 197 | 198 | r := v.cli.NewRequest("PUT", "/v1/"+pkiMountName+"/sign/"+v.Role) 199 | if err := r.SetJSONBody(opts); err != nil { 200 | return nil, err 201 | } 202 | 203 | resp, err := v.cli.RawRequestWithContext(ctx, r) 204 | if resp != nil { 205 | defer resp.Body.Close() 206 | } 207 | if resp != nil && resp.StatusCode == 404 { 208 | secret, parseErr := api.ParseSecret(resp.Body) 209 | switch parseErr { 210 | case nil: 211 | case io.EOF: 212 | return nil, nil 213 | default: 214 | return nil, err 215 | } 216 | if secret != nil && (len(secret.Warnings) > 0 || len(secret.Data) > 0) { 217 | return secret, err 218 | } 219 | } 220 | if err != nil { 221 | return nil, err 222 | } 223 | 224 | return api.ParseSecret(resp.Body) 225 | } 226 | -------------------------------------------------------------------------------- /issuers/vault/vault_suite_test.go: -------------------------------------------------------------------------------- 1 | package vault_test 2 | 3 | import ( 4 | "archive/tar" 5 | "bytes" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/x509" 9 | "crypto/x509/pkix" 10 | "encoding/base64" 11 | "encoding/pem" 12 | "fmt" 13 | "log" 14 | "math/big" 15 | "net" 16 | "net/url" 17 | "os" 18 | "testing" 19 | "time" 20 | 21 | "github.com/hashicorp/vault/api" 22 | . "github.com/onsi/ginkgo" 23 | . "github.com/onsi/gomega" 24 | "github.com/ory/dockertest/v3" 25 | "github.com/ory/dockertest/v3/docker" 26 | ) 27 | 28 | func TestVault(t *testing.T) { 29 | RegisterFailHandler(Fail) 30 | RunSpecs(t, "Vault Suite") 31 | } 32 | 33 | type vaultConfig struct { 34 | Role string 35 | RoleURISANs string 36 | Mount string 37 | Token string 38 | URL *url.URL 39 | CA *x509.Certificate 40 | CertPool *x509.CertPool 41 | } 42 | 43 | var ( 44 | pool *dockertest.Pool 45 | resource *dockertest.Resource 46 | waiter docker.CloseWaiter 47 | 48 | vaultConf, vaultTLSConf vaultConfig 49 | altMount = "mount-test-pki" 50 | defaultTTL, maxTTL time.Duration 51 | ) 52 | 53 | var _ = BeforeSuite(func() { 54 | host := "localhost" 55 | if os.Getenv("DOCKER_HOST") != "" { 56 | u, err := url.Parse(os.Getenv("DOCKER_HOST")) 57 | Expect(err).To(Succeed()) 58 | host, _, err = net.SplitHostPort(u.Host) 59 | Expect(err).To(Succeed()) 60 | } 61 | 62 | log.SetOutput(GinkgoWriter) 63 | 64 | cert, key, err := generateCertAndKey(host, net.IPv4(127, 0, 0, 1)) 65 | Expect(err).To(Succeed()) 66 | 67 | pool, err = dockertest.NewPool("") 68 | Expect(err).To(Succeed()) 69 | 70 | pool.MaxWait = time.Second * 10 71 | 72 | By("Starting the Vault container", func() { 73 | cp := x509.NewCertPool() 74 | Expect(cp.AppendCertsFromPEM(cert)).To(BeTrue()) 75 | token := "mysecrettoken" 76 | role := "test" 77 | roleURISANs := "test_uri_sans" 78 | 79 | repo := "vault" 80 | version := "1.11.0" 81 | img := repo + ":" + version 82 | _, err = pool.Client.InspectImage(img) 83 | if err != nil { 84 | // Pull image 85 | Expect(pool.Client.PullImage(docker.PullImageOptions{ 86 | Repository: repo, 87 | Tag: version, 88 | OutputStream: GinkgoWriter, 89 | }, docker.AuthConfiguration{})).To(Succeed()) 90 | } 91 | 92 | defaultTTL = 168 * time.Hour 93 | maxTTL = 720 * time.Hour 94 | c, err := pool.Client.CreateContainer(docker.CreateContainerOptions{ 95 | Name: "vault", 96 | Config: &docker.Config{ 97 | Image: img, 98 | Env: []string{ 99 | "VAULT_DEV_ROOT_TOKEN_ID=" + token, 100 | fmt.Sprintf(`VAULT_LOCAL_CONFIG={ 101 | "default_lease_ttl": "%s", 102 | "max_lease_ttl": "%s", 103 | "disable_mlock": true, 104 | "listener": [{ 105 | "tcp" :{ 106 | "address": "0.0.0.0:8201", 107 | "tls_cert_file": "/vault/file/cert.pem", 108 | "tls_key_file": "/vault/file/key.pem" 109 | } 110 | }] 111 | }`, defaultTTL, maxTTL), 112 | }, 113 | ExposedPorts: map[docker.Port]struct{}{ 114 | docker.Port("8200"): {}, 115 | docker.Port("8201"): {}, 116 | }, 117 | }, 118 | HostConfig: &docker.HostConfig{ 119 | PublishAllPorts: true, 120 | PortBindings: map[docker.Port][]docker.PortBinding{ 121 | "8200": {{HostPort: "8200"}}, 122 | "8201": {{HostPort: "8201"}}, 123 | }, 124 | }, 125 | }) 126 | Expect(err).To(Succeed()) 127 | 128 | b := &bytes.Buffer{} 129 | archive := tar.NewWriter(b) 130 | Expect(archive.WriteHeader(&tar.Header{ 131 | Name: "/cert.pem", 132 | Mode: 0o644, 133 | Size: int64(len(cert)), 134 | })).To(Succeed()) 135 | Expect(archive.Write(cert)).To(Equal(len(cert))) 136 | Expect(archive.WriteHeader(&tar.Header{ 137 | Name: "/key.pem", 138 | Mode: 0o644, 139 | Size: int64(len(key)), 140 | })).To(Succeed()) 141 | Expect(archive.Write(key)).To(Equal(len(key))) 142 | Expect(archive.Close()).To(Succeed()) 143 | 144 | Expect(pool.Client.UploadToContainer(c.ID, docker.UploadToContainerOptions{ 145 | InputStream: b, 146 | Path: "/vault/file/", 147 | })).To(Succeed()) 148 | 149 | Expect(pool.Client.StartContainer(c.ID, nil)).To(Succeed()) 150 | 151 | c, err = pool.Client.InspectContainer(c.ID) 152 | Expect(err).To(Succeed()) 153 | 154 | waiter, err = pool.Client.AttachToContainerNonBlocking(docker.AttachToContainerOptions{ 155 | Container: c.ID, 156 | OutputStream: GinkgoWriter, 157 | ErrorStream: GinkgoWriter, 158 | Stderr: true, 159 | Stdout: true, 160 | Stream: true, 161 | }) 162 | Expect(err).To(Succeed()) 163 | 164 | resource = &dockertest.Resource{Container: c} 165 | 166 | conf := api.DefaultConfig() 167 | conf.Address = "http://" + net.JoinHostPort(host, "8200") 168 | cli, err := api.NewClient(conf) 169 | Expect(err).To(Succeed()) 170 | cli.SetToken(token) 171 | 172 | // Wait for container to start. This is a no op. 173 | Expect(pool.Retry(func() error { 174 | _, err := cli.Logical().Read("pki/certs") 175 | return err 176 | })).To(Succeed()) 177 | 178 | var vaultCA *x509.Certificate 179 | // Mount PKI at /pki and /altMount 180 | for _, mountPoint := range []string{"pki", altMount} { 181 | Expect(cli.Sys().Mount(mountPoint, &api.MountInput{ 182 | Type: "pki", 183 | Config: api.MountConfigInput{ 184 | MaxLeaseTTL: "87600h", 185 | }, 186 | })).To(Succeed()) 187 | _, err = cli.Logical().Write(mountPoint+"/roles/"+role, map[string]interface{}{ 188 | "allowed_domains": "myserver.com", 189 | "allow_subdomains": true, 190 | "allow_any_name": true, 191 | "key_type": "any", 192 | "allowed_other_sans": "1.3.6.1.4.1.311.20.2.3;utf8:*", 193 | }) 194 | Expect(err).To(Succeed()) 195 | 196 | _, err = cli.Logical().Write(mountPoint+"/roles/"+roleURISANs, map[string]interface{}{ 197 | "allowed_domains": "myserver.com", 198 | "allow_subdomains": true, 199 | "allow_any_name": true, 200 | "use_csr_sans": false, 201 | "key_type": "any", 202 | "allowed_uri_sans": "spiffe://hostname/*", 203 | }) 204 | Expect(err).To(Succeed()) 205 | 206 | resp, err := cli.Logical().Write(mountPoint+"/root/generate/internal", map[string]interface{}{ 207 | "ttl": "87600h", 208 | "common_name": "my_vault", 209 | "ip_sans": c.NetworkSettings.IPAddress, 210 | "format": "der", 211 | }) 212 | Expect(err).To(Succeed()) 213 | if mountPoint == "pki" { 214 | // Parse the generated CA for the TLS connection 215 | caCertDER, err := base64.StdEncoding.DecodeString(resp.Data["certificate"].(string)) 216 | Expect(err).To(Succeed()) 217 | vaultCA, err = x509.ParseCertificate(caCertDER) 218 | Expect(err).To(Succeed()) 219 | } 220 | } 221 | 222 | vaultConf = vaultConfig{ 223 | Token: token, 224 | Role: role, 225 | RoleURISANs: roleURISANs, 226 | URL: &url.URL{ 227 | Scheme: "http", 228 | Host: net.JoinHostPort(host, "8200"), 229 | }, 230 | } 231 | vaultTLSConf = vaultConfig{ 232 | Token: token, 233 | Role: role, 234 | RoleURISANs: roleURISANs, 235 | CertPool: cp, 236 | CA: vaultCA, 237 | URL: &url.URL{ 238 | Scheme: "https", 239 | Host: net.JoinHostPort(host, "8201"), 240 | }, 241 | } 242 | }) 243 | }) 244 | 245 | var _ = AfterSuite(func() { 246 | if waiter != nil { 247 | Expect(waiter.Close()).To(Succeed()) 248 | Expect(waiter.Wait()).To(Succeed()) 249 | } 250 | if pool != nil { 251 | Expect(pool.Purge(resource)).To(Succeed()) 252 | } 253 | }) 254 | 255 | func generateCertAndKey(SAN string, IPSAN net.IP) ([]byte, []byte, error) { 256 | priv, err := rsa.GenerateKey(rand.Reader, 2048) 257 | if err != nil { 258 | return nil, nil, err 259 | } 260 | notBefore := time.Now() 261 | notAfter := notBefore.Add(time.Hour) 262 | serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) 263 | serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) 264 | if err != nil { 265 | return nil, nil, err 266 | } 267 | template := x509.Certificate{ 268 | SerialNumber: serialNumber, 269 | Subject: pkix.Name{ 270 | CommonName: "Certify Test Cert", 271 | }, 272 | NotBefore: notBefore, 273 | NotAfter: notAfter, 274 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 275 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 276 | BasicConstraintsValid: true, 277 | DNSNames: []string{SAN}, 278 | IPAddresses: []net.IP{IPSAN}, 279 | } 280 | derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, priv.Public(), priv) 281 | if err != nil { 282 | return nil, nil, err 283 | } 284 | certOut := pem.EncodeToMemory(&pem.Block{ 285 | Type: "CERTIFICATE", 286 | Bytes: derBytes, 287 | }) 288 | keyOut := pem.EncodeToMemory(&pem.Block{ 289 | Type: "RSA PRIVATE KEY", 290 | Bytes: x509.MarshalPKCS1PrivateKey(priv), 291 | }) 292 | 293 | return certOut, keyOut, nil 294 | } 295 | -------------------------------------------------------------------------------- /issuers/vault/vault_test.go: -------------------------------------------------------------------------------- 1 | package vault_test 2 | 3 | import ( 4 | "context" 5 | "crypto" 6 | "crypto/ecdsa" 7 | "crypto/elliptic" 8 | "crypto/rand" 9 | "crypto/tls" 10 | "crypto/x509" 11 | "crypto/x509/pkix" 12 | "encoding/asn1" 13 | "errors" 14 | "io/ioutil" 15 | "net" 16 | "net/http" 17 | "strings" 18 | "time" 19 | 20 | "github.com/hashicorp/vault/api" 21 | . "github.com/onsi/ginkgo" 22 | . "github.com/onsi/gomega" 23 | "google.golang.org/grpc" 24 | "google.golang.org/grpc/credentials" 25 | "google.golang.org/grpc/grpclog" 26 | 27 | "github.com/johanbrandhorst/certify" 28 | "github.com/johanbrandhorst/certify/issuers/vault" 29 | "github.com/johanbrandhorst/certify/issuers/vault/proto" 30 | ) 31 | 32 | type otherName struct { 33 | TypeID asn1.ObjectIdentifier 34 | Value string `asn1:"explicit,utf8"` 35 | } 36 | 37 | // Copied from go-spiffe v1 licensed under Apache 2.0: 38 | /* 39 | 40 | Copyright 2017 The SPIFFE Project & Scytale, Inc 41 | 42 | Licensed under the Apache License, Version 2.0 (the "License"); 43 | you may not use this file except in compliance with the License. 44 | You may obtain a copy of the License at 45 | 46 | http://www.apache.org/licenses/LICENSE-2.0 47 | 48 | Unless required by applicable law or agreed to in writing, software 49 | distributed under the License is distributed on an "AS IS" BASIS, 50 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 51 | See the License for the specific language governing permissions and 52 | limitations under the License. 53 | 54 | */ 55 | 56 | var oidExtensionSubjectAltName = asn1.ObjectIdentifier{2, 5, 29, 17} 57 | 58 | func getExtensionsFromAsn1ObjectIdentifier(certificate *x509.Certificate, id asn1.ObjectIdentifier) []pkix.Extension { 59 | var extensions []pkix.Extension 60 | 61 | for _, extension := range certificate.Extensions { 62 | if extension.Id.Equal(id) { 63 | extensions = append(extensions, extension) 64 | } 65 | } 66 | 67 | return extensions 68 | } 69 | 70 | func getURINamesFromSANExtension(sanExtension []byte) (uris []string, err error) { 71 | // RFC 5280, 4.2.1.6 72 | 73 | // SubjectAltName ::= GeneralNames 74 | // 75 | // GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName 76 | // 77 | // GeneralName ::= CHOICE { 78 | // otherName [0] OtherName, 79 | // rfc822Name [1] IA5String, 80 | // dNSName [2] IA5String, 81 | // x400Address [3] ORAddress, 82 | // directoryName [4] Name, 83 | // ediPartyName [5] EDIPartyName, 84 | // uniformResourceIdentifier [6] IA5String, 85 | // iPAddress [7] OCTET STRING, 86 | // registeredID [8] OBJECT IDENTIFIER } 87 | var seq asn1.RawValue 88 | var rest []byte 89 | if rest, err = asn1.Unmarshal(sanExtension, &seq); err != nil { 90 | return uris, err 91 | } else if len(rest) != 0 { 92 | err = errors.New("x509: trailing data after X.509 extension") 93 | return uris, err 94 | } 95 | if !seq.IsCompound || seq.Tag != 16 || seq.Class != 0 { 96 | err = asn1.StructuralError{Msg: "bad SAN sequence"} 97 | return uris, err 98 | } 99 | 100 | rest = seq.Bytes 101 | for len(rest) > 0 { 102 | var v asn1.RawValue 103 | rest, err = asn1.Unmarshal(rest, &v) 104 | if err != nil { 105 | return uris, err 106 | } 107 | if v.Tag == 6 { 108 | uris = append(uris, string(v.Bytes)) 109 | } 110 | } 111 | 112 | return uris, err 113 | } 114 | 115 | func getURINamesFromCertificate(cert *x509.Certificate) (uris []string, err error) { 116 | for _, ext := range getExtensionsFromAsn1ObjectIdentifier(cert, oidExtensionSubjectAltName) { 117 | uris, err = getURINamesFromSANExtension(ext.Value) 118 | if err != nil { 119 | return uris, err 120 | } 121 | } 122 | 123 | return uris, nil 124 | } 125 | 126 | func getOtherNames(cert *x509.Certificate) (otherNames []otherName, err error) { 127 | for _, ext := range getExtensionsFromAsn1ObjectIdentifier(cert, oidExtensionSubjectAltName) { 128 | var altName asn1.RawValue 129 | _, _ = asn1.Unmarshal(ext.Value, &altName) 130 | if altName.Class == asn1.ClassUniversal && altName.Tag == asn1.TagSequence { 131 | data := altName.Bytes 132 | for len(data) > 0 { 133 | var alt asn1.RawValue 134 | data, _ = asn1.Unmarshal(data, &alt) 135 | if alt.Class == asn1.ClassContextSpecific && alt.Tag == 0 { 136 | var oName otherName 137 | _, err = asn1.UnmarshalWithParams(alt.FullBytes, &oName, "tag:0") 138 | if err != nil { 139 | return 140 | } 141 | otherNames = append(otherNames, oName) 142 | } 143 | } 144 | } 145 | } 146 | 147 | return otherNames, nil 148 | } 149 | 150 | var _ = Describe("Vault Issuer", func() { 151 | var iss certify.Issuer 152 | var conf *certify.CertConfig 153 | 154 | BeforeEach(func() { 155 | iss = &vault.Issuer{ 156 | URL: vaultTLSConf.URL, 157 | AuthMethod: vault.ConstantToken(vaultTLSConf.Token), 158 | Role: vaultTLSConf.Role, 159 | TLSConfig: &tls.Config{ 160 | RootCAs: vaultTLSConf.CertPool, 161 | }, 162 | TimeToLive: time.Minute * 10, 163 | // Format is ";utf8:", where type_id 164 | // is an ASN.1 object identifier. 165 | OtherSubjectAlternativeNames: []string{"1.3.6.1.4.1.311.20.2.3;utf8:devops@nope.com"}, 166 | } 167 | conf = &certify.CertConfig{ 168 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 169 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 170 | }), 171 | } 172 | }) 173 | 174 | It("issues a certificate", func() { 175 | cn := "somename.com" 176 | 177 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 178 | Expect(err).NotTo(HaveOccurred()) 179 | 180 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 181 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 182 | 183 | // Check that chain is included 184 | Expect(tlsCert.Certificate).To(HaveLen(2)) 185 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 186 | Expect(err).NotTo(HaveOccurred()) 187 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 188 | 189 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 190 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.(*vault.Issuer).TimeToLive), 5*time.Second)) 191 | Expect(getOtherNames(tlsCert.Leaf)).To(ConsistOf(otherName{ 192 | TypeID: asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}), 193 | Value: "devops@nope.com", 194 | })) 195 | }) 196 | 197 | Context("with no explicit AuthMethod set", func() { 198 | It("still works", func() { 199 | cn := "somename.com" 200 | 201 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 202 | Expect(err).NotTo(HaveOccurred()) 203 | 204 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 205 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 206 | 207 | // Check that chain is included 208 | Expect(tlsCert.Certificate).To(HaveLen(2)) 209 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 210 | Expect(err).NotTo(HaveOccurred()) 211 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 212 | 213 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 214 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.(*vault.Issuer).TimeToLive), 5*time.Second)) 215 | Expect(getOtherNames(tlsCert.Leaf)).To(ConsistOf(otherName{ 216 | TypeID: asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}), 217 | Value: "devops@nope.com", 218 | })) 219 | }) 220 | }) 221 | 222 | Context("with URI SANs", func() { 223 | BeforeEach(func() { 224 | iss = &vault.Issuer{ 225 | URL: vaultTLSConf.URL, 226 | AuthMethod: vault.ConstantToken(vaultTLSConf.Token), 227 | Role: vaultTLSConf.RoleURISANs, 228 | TLSConfig: &tls.Config{ 229 | RootCAs: vaultTLSConf.CertPool, 230 | }, 231 | TimeToLive: time.Minute * 10, 232 | URISubjectAlternativeNames: []string{"spiffe://hostname/testing"}, 233 | } 234 | }) 235 | 236 | It("issues a certificate", func() { 237 | cn := "somename.com" 238 | 239 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 240 | Expect(err).NotTo(HaveOccurred()) 241 | 242 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 243 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 244 | 245 | certURIs, err := getURINamesFromCertificate(tlsCert.Leaf) 246 | Expect(err).To(Succeed()) 247 | Expect(certURIs).To(Equal([]string{"spiffe://hostname/testing"})) 248 | 249 | // Check that chain is included 250 | Expect(tlsCert.Certificate).To(HaveLen(2)) 251 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 252 | Expect(err).NotTo(HaveOccurred()) 253 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 254 | 255 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 256 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.(*vault.Issuer).TimeToLive), 5*time.Second)) 257 | }) 258 | }) 259 | 260 | Context("with one DNS SAN", func() { 261 | BeforeEach(func() { 262 | iss = &vault.Issuer{ 263 | URL: vaultTLSConf.URL, 264 | AuthMethod: vault.ConstantToken(vaultTLSConf.Token), 265 | Role: vaultTLSConf.RoleURISANs, 266 | TLSConfig: &tls.Config{ 267 | RootCAs: vaultTLSConf.CertPool, 268 | }, 269 | TimeToLive: time.Minute * 10, 270 | SubjectAlternativeNames: []string{"test.example.com"}, 271 | } 272 | }) 273 | 274 | It("issues a certificate", func() { 275 | cn := "somename.com" 276 | 277 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 278 | Expect(err).NotTo(HaveOccurred()) 279 | 280 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 281 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 282 | 283 | Expect(tlsCert.Leaf.DNSNames).To(ConsistOf("test.example.com")) 284 | 285 | // Check that chain is included 286 | Expect(tlsCert.Certificate).To(HaveLen(2)) 287 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 288 | Expect(err).NotTo(HaveOccurred()) 289 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 290 | 291 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 292 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.(*vault.Issuer).TimeToLive), 5*time.Second)) 293 | }) 294 | }) 295 | 296 | Context("with multiple DNS SANs", func() { 297 | BeforeEach(func() { 298 | iss = &vault.Issuer{ 299 | URL: vaultTLSConf.URL, 300 | AuthMethod: vault.ConstantToken(vaultTLSConf.Token), 301 | Role: vaultTLSConf.RoleURISANs, 302 | TLSConfig: &tls.Config{ 303 | RootCAs: vaultTLSConf.CertPool, 304 | }, 305 | TimeToLive: time.Minute * 10, 306 | SubjectAlternativeNames: []string{"test.example.com", "foobar.example.com"}, 307 | } 308 | }) 309 | 310 | It("issues a certificate", func() { 311 | cn := "somename.com" 312 | 313 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 314 | Expect(err).NotTo(HaveOccurred()) 315 | 316 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 317 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 318 | 319 | Expect(tlsCert.Leaf.DNSNames).To(ConsistOf("test.example.com", "foobar.example.com")) 320 | 321 | // Check that chain is included 322 | Expect(tlsCert.Certificate).To(HaveLen(2)) 323 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 324 | Expect(err).NotTo(HaveOccurred()) 325 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 326 | 327 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 328 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.(*vault.Issuer).TimeToLive), 5*time.Second)) 329 | }) 330 | }) 331 | 332 | Context("with one IP SAN", func() { 333 | BeforeEach(func() { 334 | iss = &vault.Issuer{ 335 | URL: vaultTLSConf.URL, 336 | AuthMethod: vault.ConstantToken(vaultTLSConf.Token), 337 | Role: vaultTLSConf.RoleURISANs, 338 | TLSConfig: &tls.Config{ 339 | RootCAs: vaultTLSConf.CertPool, 340 | }, 341 | TimeToLive: time.Minute * 10, 342 | IPSubjectAlternativeNames: []string{"127.0.0.1"}, 343 | } 344 | }) 345 | 346 | It("issues a certificate", func() { 347 | cn := "somename.com" 348 | 349 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 350 | Expect(err).NotTo(HaveOccurred()) 351 | 352 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 353 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 354 | 355 | Expect(tlsCert.Leaf.IPAddresses).To(HaveLen(1)) 356 | Expect(tlsCert.Leaf.IPAddresses[0].Equal(net.IPv4(127, 0, 0, 1))).To(BeTrue()) 357 | 358 | // Check that chain is included 359 | Expect(tlsCert.Certificate).To(HaveLen(2)) 360 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 361 | Expect(err).NotTo(HaveOccurred()) 362 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 363 | 364 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 365 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.(*vault.Issuer).TimeToLive), 5*time.Second)) 366 | }) 367 | }) 368 | 369 | Context("with a non-standard mount point", func() { 370 | BeforeEach(func() { 371 | iss = &vault.Issuer{ 372 | URL: vaultTLSConf.URL, 373 | AuthMethod: vault.ConstantToken(vaultTLSConf.Token), 374 | Mount: altMount, 375 | Role: vaultTLSConf.Role, 376 | TLSConfig: &tls.Config{ 377 | RootCAs: vaultTLSConf.CertPool, 378 | }, 379 | TimeToLive: time.Minute * 10, 380 | // Format is ";utf8:", where type_id 381 | // is an ASN.1 object identifier. 382 | OtherSubjectAlternativeNames: []string{"1.3.6.1.4.1.311.20.2.3;utf8:devops@nope.com"}, 383 | } 384 | }) 385 | 386 | It("issues a certificate", func() { 387 | cn := "somename.com" 388 | 389 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 390 | Expect(err).NotTo(HaveOccurred()) 391 | 392 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 393 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 394 | 395 | // Check that chain is included 396 | Expect(tlsCert.Certificate).To(HaveLen(2)) 397 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 398 | Expect(err).NotTo(HaveOccurred()) 399 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 400 | 401 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 402 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.(*vault.Issuer).TimeToLive), 5*time.Second)) 403 | Expect(getOtherNames(tlsCert.Leaf)).To(ConsistOf(otherName{ 404 | TypeID: asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}), 405 | Value: "devops@nope.com", 406 | })) 407 | }) 408 | }) 409 | 410 | Context("when specifying some SANs, IPSANs", func() { 411 | It("issues a certificate with the SANs and IPSANs", func() { 412 | conf.SubjectAlternativeNames = []string{"extraname.com", "otherextraname.com"} 413 | conf.IPSubjectAlternativeNames = []net.IP{net.IPv4(1, 2, 3, 4), net.IPv6loopback} 414 | cn := "somename.com" 415 | 416 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 417 | Expect(err).NotTo(HaveOccurred()) 418 | 419 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 420 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 421 | Expect(tlsCert.Leaf.DNSNames).To(Equal(conf.SubjectAlternativeNames)) 422 | Expect(tlsCert.Leaf.IPAddresses).To(HaveLen(len(conf.IPSubjectAlternativeNames))) 423 | for i, ip := range tlsCert.Leaf.IPAddresses { 424 | Expect(ip.Equal(conf.IPSubjectAlternativeNames[i])).To(BeTrue()) 425 | } 426 | 427 | // Check that chain is included 428 | Expect(tlsCert.Certificate).To(HaveLen(2)) 429 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 430 | Expect(err).NotTo(HaveOccurred()) 431 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 432 | 433 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 434 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.(*vault.Issuer).TimeToLive), 5*time.Second)) 435 | Expect(getOtherNames(tlsCert.Leaf)).To(ConsistOf(otherName{ 436 | TypeID: asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}), 437 | Value: "devops@nope.com", 438 | })) 439 | }) 440 | }) 441 | 442 | Context("when the TTL is not specified", func() { 443 | It("issues a certificate with the role TTL", func() { 444 | iss.(*vault.Issuer).TimeToLive = 0 445 | 446 | cn := "somename.com" 447 | 448 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 449 | Expect(err).NotTo(HaveOccurred()) 450 | 451 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 452 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 453 | 454 | // Check that chain is included 455 | Expect(tlsCert.Certificate).To(HaveLen(2)) 456 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 457 | Expect(err).NotTo(HaveOccurred()) 458 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 459 | 460 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 461 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(defaultTTL), 5*time.Second)) 462 | Expect(getOtherNames(tlsCert.Leaf)).To(ConsistOf(otherName{ 463 | TypeID: asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}), 464 | Value: "devops@nope.com", 465 | })) 466 | }) 467 | }) 468 | }) 469 | 470 | var _ = Describe("Vault HTTP Issuer", func() { 471 | var iss certify.Issuer 472 | var conf *certify.CertConfig 473 | 474 | BeforeEach(func() { 475 | iss = &vault.Issuer{ 476 | URL: vaultConf.URL, 477 | AuthMethod: vault.ConstantToken(vaultTLSConf.Token), 478 | Role: vaultConf.Role, 479 | TimeToLive: time.Minute * 10, 480 | // Format is ";utf8:", where type_id 481 | // is an ASN.1 object identifier. 482 | OtherSubjectAlternativeNames: []string{"1.3.6.1.4.1.311.20.2.3;utf8:devops@nope.com"}, 483 | } 484 | conf = &certify.CertConfig{ 485 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 486 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 487 | }), 488 | } 489 | }) 490 | 491 | It("issues a certificate", func() { 492 | cn := "somename.com" 493 | 494 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 495 | Expect(err).NotTo(HaveOccurred()) 496 | 497 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 498 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 499 | 500 | // Check that chain is included 501 | Expect(tlsCert.Certificate).To(HaveLen(2)) 502 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 503 | Expect(err).NotTo(HaveOccurred()) 504 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 505 | 506 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 507 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.(*vault.Issuer).TimeToLive), 5*time.Second)) 508 | Expect(getOtherNames(tlsCert.Leaf)).To(ConsistOf(otherName{ 509 | TypeID: asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}), 510 | Value: "devops@nope.com", 511 | })) 512 | }) 513 | 514 | Context("when specifying some SANs, IPSANs", func() { 515 | It("issues a certificate with the SANs and IPSANs", func() { 516 | conf.SubjectAlternativeNames = []string{"extraname.com", "otherextraname.com"} 517 | conf.IPSubjectAlternativeNames = []net.IP{net.IPv4(1, 2, 3, 4), net.IPv6loopback} 518 | cn := "somename.com" 519 | 520 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 521 | Expect(err).NotTo(HaveOccurred()) 522 | 523 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 524 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 525 | Expect(tlsCert.Leaf.DNSNames).To(Equal(conf.SubjectAlternativeNames)) 526 | Expect(tlsCert.Leaf.IPAddresses).To(HaveLen(len(conf.IPSubjectAlternativeNames))) 527 | for i, ip := range tlsCert.Leaf.IPAddresses { 528 | Expect(ip.Equal(conf.IPSubjectAlternativeNames[i])).To(BeTrue()) 529 | } 530 | 531 | // Check that chain is included 532 | Expect(tlsCert.Certificate).To(HaveLen(2)) 533 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 534 | Expect(err).NotTo(HaveOccurred()) 535 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 536 | 537 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 538 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.(*vault.Issuer).TimeToLive), 5*time.Second)) 539 | Expect(getOtherNames(tlsCert.Leaf)).To(ConsistOf(otherName{ 540 | TypeID: asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}), 541 | Value: "devops@nope.com", 542 | })) 543 | }) 544 | }) 545 | 546 | Context("when the TTL is not specified", func() { 547 | It("issues a certificate with the role TTL", func() { 548 | iss.(*vault.Issuer).TimeToLive = 0 549 | 550 | cn := "somename.com" 551 | 552 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 553 | Expect(err).NotTo(HaveOccurred()) 554 | 555 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 556 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 557 | 558 | // Check that chain is included 559 | Expect(tlsCert.Certificate).To(HaveLen(2)) 560 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 561 | Expect(err).NotTo(HaveOccurred()) 562 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 563 | 564 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 565 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(defaultTTL), 5*time.Second)) 566 | Expect(getOtherNames(tlsCert.Leaf)).To(ConsistOf(otherName{ 567 | TypeID: asn1.ObjectIdentifier([]int{1, 3, 6, 1, 4, 1, 311, 20, 2, 3}), 568 | Value: "devops@nope.com", 569 | })) 570 | }) 571 | }) 572 | }) 573 | 574 | var _ = Describe("Using a pre-created client", func() { 575 | It("issues a certificate", func() { 576 | vConf := api.DefaultConfig() 577 | vConf.HttpClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{ 578 | RootCAs: vaultTLSConf.CertPool, 579 | } 580 | 581 | vConf.Address = vaultTLSConf.URL.String() 582 | cli, err := api.NewClient(vConf) 583 | Expect(err).To(Succeed()) 584 | 585 | cli.SetToken(vaultTLSConf.Token) 586 | iss := vault.FromClient(cli, vaultTLSConf.Role) 587 | iss.TimeToLive = 10 * time.Minute 588 | 589 | conf := &certify.CertConfig{ 590 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 591 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 592 | }), 593 | } 594 | cn := "somename.com" 595 | 596 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 597 | Expect(err).NotTo(HaveOccurred()) 598 | 599 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 600 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 601 | 602 | // Check that chain is included 603 | Expect(tlsCert.Certificate).To(HaveLen(2)) 604 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 605 | Expect(err).NotTo(HaveOccurred()) 606 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 607 | 608 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 609 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.TimeToLive), 5*time.Second)) 610 | }) 611 | }) 612 | 613 | var _ = Describe("When an AuthMethod is not explicitly set", func() { 614 | It("still works", func() { 615 | iss := &vault.Issuer{ 616 | URL: vaultTLSConf.URL, 617 | Token: vaultTLSConf.Token, 618 | Role: vaultTLSConf.Role, 619 | TLSConfig: &tls.Config{ 620 | RootCAs: vaultTLSConf.CertPool, 621 | }, 622 | TimeToLive: time.Minute * 10, 623 | } 624 | conf := &certify.CertConfig{ 625 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 626 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 627 | }), 628 | } 629 | cn := "somename.com" 630 | 631 | tlsCert, err := iss.Issue(context.Background(), cn, conf) 632 | Expect(err).NotTo(HaveOccurred()) 633 | 634 | Expect(tlsCert.Leaf).NotTo(BeNil(), "tlsCert.Leaf should be populated by Issue to track expiry") 635 | Expect(tlsCert.Leaf.Subject.CommonName).To(Equal(cn)) 636 | 637 | // Check that chain is included 638 | Expect(tlsCert.Certificate).To(HaveLen(2)) 639 | caCert, err := x509.ParseCertificate(tlsCert.Certificate[1]) 640 | Expect(err).NotTo(HaveOccurred()) 641 | Expect(caCert.Subject.SerialNumber).To(Equal(tlsCert.Leaf.Issuer.SerialNumber)) 642 | 643 | Expect(tlsCert.Leaf.NotBefore).To(BeTemporally("<", time.Now())) 644 | Expect(tlsCert.Leaf.NotAfter).To(BeTemporally("~", time.Now().Add(iss.TimeToLive), 5*time.Second)) 645 | }) 646 | }) 647 | 648 | var _ = Describe("When using RenewingToken", func() { 649 | It("renews the token when it is within the renewal period", func() { 650 | vConf := api.DefaultConfig() 651 | vConf.HttpClient.Transport.(*http.Transport).TLSClientConfig = &tls.Config{ 652 | RootCAs: vaultTLSConf.CertPool, 653 | } 654 | vConf.Address = vaultTLSConf.URL.String() 655 | cli, err := api.NewClient(vConf) 656 | Expect(err).To(Succeed()) 657 | 658 | cli.SetToken(vaultTLSConf.Token) 659 | 660 | ttl := time.Minute 661 | expiry := time.Now().Add(ttl) 662 | 663 | opts := &api.TokenCreateRequest{ 664 | TTL: ttl.String(), 665 | Renewable: func() *bool { t := true; return &t }(), 666 | } 667 | tok, err := cli.Auth().Token().Create(opts) 668 | Expect(err).To(Succeed()) 669 | 670 | tokTTL, err := tok.TokenTTL() 671 | Expect(err).To(Succeed()) 672 | Expect(tokTTL).To(BeNumerically("~", time.Until(expiry), time.Second)) 673 | 674 | it := tok.Auth.ClientToken 675 | rt := &vault.RenewingToken{ 676 | Initial: it, 677 | RenewBefore: time.Hour, 678 | TimeToLive: ttl, // Should renew immediately, since TTL < RenewBefore 679 | } 680 | defer func() { 681 | Expect(rt.Close()).To(Succeed()) 682 | }() 683 | 684 | iss := &vault.Issuer{ 685 | URL: vaultTLSConf.URL, 686 | AuthMethod: rt, 687 | Role: vaultTLSConf.Role, 688 | TLSConfig: &tls.Config{ 689 | RootCAs: vaultTLSConf.CertPool, 690 | }, 691 | TimeToLive: time.Minute * 10, 692 | } 693 | conf := &certify.CertConfig{ 694 | KeyGenerator: keyGeneratorFunc(func() (crypto.PrivateKey, error) { 695 | return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 696 | }), 697 | } 698 | cn := "somename.com" 699 | 700 | _, err = iss.Issue(context.Background(), cn, conf) 701 | Expect(err).To(Succeed()) 702 | 703 | time.Sleep(2 * time.Second) // Should cause token to be renewed in the background. 704 | 705 | _, err = iss.Issue(context.Background(), cn, conf) 706 | Expect(err).To(Succeed()) 707 | 708 | newTok, err := cli.Auth().Token().Lookup(it) 709 | Expect(err).To(Succeed()) 710 | 711 | newTTL, err := newTok.TokenTTL() 712 | Expect(err).To(Succeed()) 713 | 714 | // Assert that the token now has a new expiry 715 | Expect(time.Now().Add(newTTL)).To(BeTemporally(">", expiry, time.Second)) 716 | }) 717 | }) 718 | 719 | type keyGeneratorFunc func() (crypto.PrivateKey, error) 720 | 721 | func (kgf keyGeneratorFunc) Generate() (crypto.PrivateKey, error) { 722 | return kgf() 723 | } 724 | 725 | type backend struct{} 726 | 727 | func (_ backend) Ping(_ context.Context, _ *proto.Void) (*proto.Void, error) { 728 | return new(proto.Void), nil 729 | } 730 | 731 | var _ = Describe("gRPC Test", func() { 732 | Context("when using mutual TLS authentication", func() { 733 | var cb *certify.Certify 734 | addr := "localhost:0" 735 | var srv *grpc.Server 736 | var cc *grpc.ClientConn 737 | 738 | AfterEach(func() { 739 | if srv != nil { 740 | srv.GracefulStop() 741 | } 742 | if cc != nil { 743 | Expect(cc.Close()).To(Succeed()) 744 | } 745 | }) 746 | 747 | It("allows client and server to talk to each other", func() { 748 | var lis net.Listener 749 | var cli proto.TestClient 750 | By("Creating the Certify", func() { 751 | cb = &certify.Certify{ 752 | CommonName: "Certify", 753 | Issuer: &vault.Issuer{ 754 | URL: vaultTLSConf.URL, 755 | AuthMethod: vault.ConstantToken(vaultTLSConf.Token), 756 | Role: vaultTLSConf.Role, 757 | TLSConfig: &tls.Config{ 758 | RootCAs: vaultTLSConf.CertPool, 759 | }, 760 | }, 761 | Cache: certify.NewMemCache(), 762 | RenewBefore: time.Hour, 763 | } 764 | }) 765 | 766 | By("Starting the gRPC Server", func() { 767 | var err error 768 | 769 | grpclog.SetLoggerV2(grpclog.NewLoggerV2(GinkgoWriter, ioutil.Discard, ioutil.Discard)) 770 | lis, err = net.Listen("tcp", addr) 771 | Expect(err).To(Succeed()) 772 | 773 | cp := x509.NewCertPool() 774 | cp.AddCert(vaultTLSConf.CA) 775 | tlsConfig := &tls.Config{ 776 | GetCertificate: cb.GetCertificate, 777 | ClientCAs: cp, 778 | ClientAuth: tls.RequireAndVerifyClientCert, 779 | } 780 | 781 | srv = grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig))) 782 | proto.RegisterTestServer(srv, &backend{}) 783 | 784 | go func() { 785 | _ = srv.Serve(lis) 786 | }() 787 | }) 788 | 789 | By("Creating the client", func() { 790 | cp := x509.NewCertPool() 791 | cp.AddCert(vaultTLSConf.CA) 792 | tlsConfig := &tls.Config{ 793 | GetClientCertificate: cb.GetClientCertificate, 794 | RootCAs: cp, 795 | ServerName: strings.Split(addr, ":")[0], 796 | } 797 | var err error 798 | cc, err = grpc.Dial(lis.Addr().String(), grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig))) 799 | Expect(err).To(Succeed()) 800 | 801 | cli = proto.NewTestClient(cc) 802 | }) 803 | 804 | _, err := cli.Ping(context.Background(), new(proto.Void)) 805 | Expect(err).To(Succeed()) 806 | }) 807 | }) 808 | }) 809 | -------------------------------------------------------------------------------- /keys.go: -------------------------------------------------------------------------------- 1 | package certify 2 | 3 | import ( 4 | "crypto" 5 | "crypto/ecdsa" 6 | "crypto/elliptic" 7 | "crypto/rand" 8 | "sync" 9 | ) 10 | 11 | type singletonKey struct { 12 | key crypto.PrivateKey 13 | err error 14 | o sync.Once 15 | } 16 | 17 | func (s *singletonKey) Generate() (crypto.PrivateKey, error) { 18 | s.o.Do(func() { 19 | s.key, s.err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) 20 | }) 21 | 22 | return s.key, s.err 23 | } 24 | -------------------------------------------------------------------------------- /logger.go: -------------------------------------------------------------------------------- 1 | package certify 2 | 3 | // Logger must be implemented to log events. See 4 | // https://logur.dev/logur for some adapters 5 | // for popular logging libraries. 6 | type Logger interface { 7 | Trace(msg string, fields ...map[string]interface{}) 8 | Debug(msg string, fields ...map[string]interface{}) 9 | Info(msg string, fields ...map[string]interface{}) 10 | Warn(msg string, fields ...map[string]interface{}) 11 | Error(msg string, fields ...map[string]interface{}) 12 | } 13 | 14 | type noopLogger struct{} 15 | 16 | func (*noopLogger) Trace(msg string, fields ...map[string]interface{}) {} 17 | func (*noopLogger) Debug(msg string, fields ...map[string]interface{}) {} 18 | func (*noopLogger) Info(msg string, fields ...map[string]interface{}) {} 19 | func (*noopLogger) Warn(msg string, fields ...map[string]interface{}) {} 20 | func (*noopLogger) Error(msg string, fields ...map[string]interface{}) {} 21 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/johanbrandhorst/certify/902828ec26610adadb4720a1424680ebcc925a5e/logo.png -------------------------------------------------------------------------------- /mocks/issuer.mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by moq; DO NOT EDIT. 2 | // github.com/matryer/moq 3 | 4 | package mocks 5 | 6 | import ( 7 | "context" 8 | "crypto/tls" 9 | "github.com/johanbrandhorst/certify" 10 | "sync" 11 | ) 12 | 13 | var ( 14 | lockIssuerMockIssue sync.RWMutex 15 | ) 16 | 17 | // Ensure, that IssuerMock does implement Issuer. 18 | // If this is not the case, regenerate this file with moq. 19 | var _ certify.Issuer = &IssuerMock{} 20 | 21 | // IssuerMock is a mock implementation of Issuer. 22 | // 23 | // func TestSomethingThatUsesIssuer(t *testing.T) { 24 | // 25 | // // make and configure a mocked Issuer 26 | // mockedIssuer := &IssuerMock{ 27 | // IssueFunc: func(in1 context.Context, in2 string, in3 *certify.CertConfig) (*tls.Certificate, error) { 28 | // panic("mock out the Issue method") 29 | // }, 30 | // } 31 | // 32 | // // use mockedIssuer in code that requires Issuer 33 | // // and then make assertions. 34 | // 35 | // } 36 | type IssuerMock struct { 37 | // IssueFunc mocks the Issue method. 38 | IssueFunc func(in1 context.Context, in2 string, in3 *certify.CertConfig) (*tls.Certificate, error) 39 | 40 | // calls tracks calls to the methods. 41 | calls struct { 42 | // Issue holds details about calls to the Issue method. 43 | Issue []struct { 44 | // In1 is the in1 argument value. 45 | In1 context.Context 46 | // In2 is the in2 argument value. 47 | In2 string 48 | // In3 is the in3 argument value. 49 | In3 *certify.CertConfig 50 | } 51 | } 52 | } 53 | 54 | // Issue calls IssueFunc. 55 | func (mock *IssuerMock) Issue(in1 context.Context, in2 string, in3 *certify.CertConfig) (*tls.Certificate, error) { 56 | if mock.IssueFunc == nil { 57 | panic("IssuerMock.IssueFunc: method is nil but Issuer.Issue was just called") 58 | } 59 | callInfo := struct { 60 | In1 context.Context 61 | In2 string 62 | In3 *certify.CertConfig 63 | }{ 64 | In1: in1, 65 | In2: in2, 66 | In3: in3, 67 | } 68 | lockIssuerMockIssue.Lock() 69 | mock.calls.Issue = append(mock.calls.Issue, callInfo) 70 | lockIssuerMockIssue.Unlock() 71 | return mock.IssueFunc(in1, in2, in3) 72 | } 73 | 74 | // IssueCalls gets all the calls that were made to Issue. 75 | // Check the length with: 76 | // len(mockedIssuer.IssueCalls()) 77 | func (mock *IssuerMock) IssueCalls() []struct { 78 | In1 context.Context 79 | In2 string 80 | In3 *certify.CertConfig 81 | } { 82 | var calls []struct { 83 | In1 context.Context 84 | In2 string 85 | In3 *certify.CertConfig 86 | } 87 | lockIssuerMockIssue.RLock() 88 | calls = mock.calls.Issue 89 | lockIssuerMockIssue.RUnlock() 90 | return calls 91 | } 92 | -------------------------------------------------------------------------------- /mocks/logger.mock.go: -------------------------------------------------------------------------------- 1 | // Code generated by moq; DO NOT EDIT. 2 | // github.com/matryer/moq 3 | 4 | package mocks 5 | 6 | import ( 7 | "github.com/johanbrandhorst/certify" 8 | "sync" 9 | ) 10 | 11 | var ( 12 | lockLoggerMockDebug sync.RWMutex 13 | lockLoggerMockError sync.RWMutex 14 | lockLoggerMockInfo sync.RWMutex 15 | lockLoggerMockTrace sync.RWMutex 16 | lockLoggerMockWarn sync.RWMutex 17 | ) 18 | 19 | // Ensure, that LoggerMock does implement Logger. 20 | // If this is not the case, regenerate this file with moq. 21 | var _ certify.Logger = &LoggerMock{} 22 | 23 | // LoggerMock is a mock implementation of Logger. 24 | // 25 | // func TestSomethingThatUsesLogger(t *testing.T) { 26 | // 27 | // // make and configure a mocked Logger 28 | // mockedLogger := &LoggerMock{ 29 | // DebugFunc: func(msg string, fields ...map[string]interface{}) { 30 | // panic("mock out the Debug method") 31 | // }, 32 | // ErrorFunc: func(msg string, fields ...map[string]interface{}) { 33 | // panic("mock out the Error method") 34 | // }, 35 | // InfoFunc: func(msg string, fields ...map[string]interface{}) { 36 | // panic("mock out the Info method") 37 | // }, 38 | // TraceFunc: func(msg string, fields ...map[string]interface{}) { 39 | // panic("mock out the Trace method") 40 | // }, 41 | // WarnFunc: func(msg string, fields ...map[string]interface{}) { 42 | // panic("mock out the Warn method") 43 | // }, 44 | // } 45 | // 46 | // // use mockedLogger in code that requires Logger 47 | // // and then make assertions. 48 | // 49 | // } 50 | type LoggerMock struct { 51 | // DebugFunc mocks the Debug method. 52 | DebugFunc func(msg string, fields ...map[string]interface{}) 53 | 54 | // ErrorFunc mocks the Error method. 55 | ErrorFunc func(msg string, fields ...map[string]interface{}) 56 | 57 | // InfoFunc mocks the Info method. 58 | InfoFunc func(msg string, fields ...map[string]interface{}) 59 | 60 | // TraceFunc mocks the Trace method. 61 | TraceFunc func(msg string, fields ...map[string]interface{}) 62 | 63 | // WarnFunc mocks the Warn method. 64 | WarnFunc func(msg string, fields ...map[string]interface{}) 65 | 66 | // calls tracks calls to the methods. 67 | calls struct { 68 | // Debug holds details about calls to the Debug method. 69 | Debug []struct { 70 | // Msg is the msg argument value. 71 | Msg string 72 | // Fields is the fields argument value. 73 | Fields []map[string]interface{} 74 | } 75 | // Error holds details about calls to the Error method. 76 | Error []struct { 77 | // Msg is the msg argument value. 78 | Msg string 79 | // Fields is the fields argument value. 80 | Fields []map[string]interface{} 81 | } 82 | // Info holds details about calls to the Info method. 83 | Info []struct { 84 | // Msg is the msg argument value. 85 | Msg string 86 | // Fields is the fields argument value. 87 | Fields []map[string]interface{} 88 | } 89 | // Trace holds details about calls to the Trace method. 90 | Trace []struct { 91 | // Msg is the msg argument value. 92 | Msg string 93 | // Fields is the fields argument value. 94 | Fields []map[string]interface{} 95 | } 96 | // Warn holds details about calls to the Warn method. 97 | Warn []struct { 98 | // Msg is the msg argument value. 99 | Msg string 100 | // Fields is the fields argument value. 101 | Fields []map[string]interface{} 102 | } 103 | } 104 | } 105 | 106 | // Debug calls DebugFunc. 107 | func (mock *LoggerMock) Debug(msg string, fields ...map[string]interface{}) { 108 | if mock.DebugFunc == nil { 109 | panic("LoggerMock.DebugFunc: method is nil but Logger.Debug was just called") 110 | } 111 | callInfo := struct { 112 | Msg string 113 | Fields []map[string]interface{} 114 | }{ 115 | Msg: msg, 116 | Fields: fields, 117 | } 118 | lockLoggerMockDebug.Lock() 119 | mock.calls.Debug = append(mock.calls.Debug, callInfo) 120 | lockLoggerMockDebug.Unlock() 121 | mock.DebugFunc(msg, fields...) 122 | } 123 | 124 | // DebugCalls gets all the calls that were made to Debug. 125 | // Check the length with: 126 | // len(mockedLogger.DebugCalls()) 127 | func (mock *LoggerMock) DebugCalls() []struct { 128 | Msg string 129 | Fields []map[string]interface{} 130 | } { 131 | var calls []struct { 132 | Msg string 133 | Fields []map[string]interface{} 134 | } 135 | lockLoggerMockDebug.RLock() 136 | calls = mock.calls.Debug 137 | lockLoggerMockDebug.RUnlock() 138 | return calls 139 | } 140 | 141 | // Error calls ErrorFunc. 142 | func (mock *LoggerMock) Error(msg string, fields ...map[string]interface{}) { 143 | if mock.ErrorFunc == nil { 144 | panic("LoggerMock.ErrorFunc: method is nil but Logger.Error was just called") 145 | } 146 | callInfo := struct { 147 | Msg string 148 | Fields []map[string]interface{} 149 | }{ 150 | Msg: msg, 151 | Fields: fields, 152 | } 153 | lockLoggerMockError.Lock() 154 | mock.calls.Error = append(mock.calls.Error, callInfo) 155 | lockLoggerMockError.Unlock() 156 | mock.ErrorFunc(msg, fields...) 157 | } 158 | 159 | // ErrorCalls gets all the calls that were made to Error. 160 | // Check the length with: 161 | // len(mockedLogger.ErrorCalls()) 162 | func (mock *LoggerMock) ErrorCalls() []struct { 163 | Msg string 164 | Fields []map[string]interface{} 165 | } { 166 | var calls []struct { 167 | Msg string 168 | Fields []map[string]interface{} 169 | } 170 | lockLoggerMockError.RLock() 171 | calls = mock.calls.Error 172 | lockLoggerMockError.RUnlock() 173 | return calls 174 | } 175 | 176 | // Info calls InfoFunc. 177 | func (mock *LoggerMock) Info(msg string, fields ...map[string]interface{}) { 178 | if mock.InfoFunc == nil { 179 | panic("LoggerMock.InfoFunc: method is nil but Logger.Info was just called") 180 | } 181 | callInfo := struct { 182 | Msg string 183 | Fields []map[string]interface{} 184 | }{ 185 | Msg: msg, 186 | Fields: fields, 187 | } 188 | lockLoggerMockInfo.Lock() 189 | mock.calls.Info = append(mock.calls.Info, callInfo) 190 | lockLoggerMockInfo.Unlock() 191 | mock.InfoFunc(msg, fields...) 192 | } 193 | 194 | // InfoCalls gets all the calls that were made to Info. 195 | // Check the length with: 196 | // len(mockedLogger.InfoCalls()) 197 | func (mock *LoggerMock) InfoCalls() []struct { 198 | Msg string 199 | Fields []map[string]interface{} 200 | } { 201 | var calls []struct { 202 | Msg string 203 | Fields []map[string]interface{} 204 | } 205 | lockLoggerMockInfo.RLock() 206 | calls = mock.calls.Info 207 | lockLoggerMockInfo.RUnlock() 208 | return calls 209 | } 210 | 211 | // Trace calls TraceFunc. 212 | func (mock *LoggerMock) Trace(msg string, fields ...map[string]interface{}) { 213 | if mock.TraceFunc == nil { 214 | panic("LoggerMock.TraceFunc: method is nil but Logger.Trace was just called") 215 | } 216 | callInfo := struct { 217 | Msg string 218 | Fields []map[string]interface{} 219 | }{ 220 | Msg: msg, 221 | Fields: fields, 222 | } 223 | lockLoggerMockTrace.Lock() 224 | mock.calls.Trace = append(mock.calls.Trace, callInfo) 225 | lockLoggerMockTrace.Unlock() 226 | mock.TraceFunc(msg, fields...) 227 | } 228 | 229 | // TraceCalls gets all the calls that were made to Trace. 230 | // Check the length with: 231 | // len(mockedLogger.TraceCalls()) 232 | func (mock *LoggerMock) TraceCalls() []struct { 233 | Msg string 234 | Fields []map[string]interface{} 235 | } { 236 | var calls []struct { 237 | Msg string 238 | Fields []map[string]interface{} 239 | } 240 | lockLoggerMockTrace.RLock() 241 | calls = mock.calls.Trace 242 | lockLoggerMockTrace.RUnlock() 243 | return calls 244 | } 245 | 246 | // Warn calls WarnFunc. 247 | func (mock *LoggerMock) Warn(msg string, fields ...map[string]interface{}) { 248 | if mock.WarnFunc == nil { 249 | panic("LoggerMock.WarnFunc: method is nil but Logger.Warn was just called") 250 | } 251 | callInfo := struct { 252 | Msg string 253 | Fields []map[string]interface{} 254 | }{ 255 | Msg: msg, 256 | Fields: fields, 257 | } 258 | lockLoggerMockWarn.Lock() 259 | mock.calls.Warn = append(mock.calls.Warn, callInfo) 260 | lockLoggerMockWarn.Unlock() 261 | mock.WarnFunc(msg, fields...) 262 | } 263 | 264 | // WarnCalls gets all the calls that were made to Warn. 265 | // Check the length with: 266 | // len(mockedLogger.WarnCalls()) 267 | func (mock *LoggerMock) WarnCalls() []struct { 268 | Msg string 269 | Fields []map[string]interface{} 270 | } { 271 | var calls []struct { 272 | Msg string 273 | Fields []map[string]interface{} 274 | } 275 | lockLoggerMockWarn.RLock() 276 | calls = mock.calls.Warn 277 | lockLoggerMockWarn.RUnlock() 278 | return calls 279 | } 280 | -------------------------------------------------------------------------------- /mocks/net.mock.go: -------------------------------------------------------------------------------- 1 | package mocks 2 | 3 | import ( 4 | "net" 5 | "time" 6 | ) 7 | 8 | type ConnMock struct { 9 | Remote net.Addr 10 | } 11 | 12 | func (c ConnMock) Read(b []byte) (n int, err error) { 13 | panic("not implemented") 14 | } 15 | 16 | func (c ConnMock) Write(b []byte) (n int, err error) { 17 | panic("not implemented") 18 | } 19 | 20 | func (c ConnMock) Close() error { 21 | panic("not implemented") 22 | } 23 | 24 | func (c ConnMock) LocalAddr() net.Addr { 25 | panic("not implemented") 26 | } 27 | 28 | func (c ConnMock) RemoteAddr() net.Addr { 29 | return c.Remote 30 | } 31 | 32 | func (c ConnMock) SetDeadline(t time.Time) error { 33 | panic("not implemented") 34 | } 35 | 36 | func (c ConnMock) SetReadDeadline(t time.Time) error { 37 | panic("not implemented") 38 | } 39 | 40 | func (c *ConnMock) SetWriteDeadline(t time.Time) error { 41 | panic("not implemented") 42 | } 43 | 44 | type AddrMock struct { 45 | S string 46 | } 47 | 48 | func (a AddrMock) Network() string { 49 | panic("not implemented") 50 | } 51 | 52 | func (a AddrMock) String() string { 53 | return a.S 54 | } 55 | -------------------------------------------------------------------------------- /request_context.go: -------------------------------------------------------------------------------- 1 | //go:build !go1.17 2 | // +build !go1.17 3 | 4 | package certify 5 | 6 | import ( 7 | "context" 8 | "crypto/tls" 9 | ) 10 | 11 | func getRequestContext(hello *tls.ClientHelloInfo) context.Context { 12 | return context.Background() 13 | } 14 | 15 | func getClientRequestContext(cri *tls.CertificateRequestInfo) context.Context { 16 | return context.Background() 17 | } 18 | -------------------------------------------------------------------------------- /request_context_go_1.17.go: -------------------------------------------------------------------------------- 1 | //go:build go1.17 2 | // +build go1.17 3 | 4 | package certify 5 | 6 | import ( 7 | "context" 8 | "crypto/tls" 9 | ) 10 | 11 | func getRequestContext(hello *tls.ClientHelloInfo) context.Context { 12 | ctx := hello.Context() 13 | // This is only necessary because we can't set the context in our tests 14 | if ctx == nil { 15 | return context.Background() 16 | } 17 | return ctx 18 | } 19 | 20 | func getClientRequestContext(cri *tls.CertificateRequestInfo) context.Context { 21 | ctx := cri.Context() 22 | // This is only necessary because we can't set the context in our tests 23 | if ctx == nil { 24 | return context.Background() 25 | } 26 | return ctx 27 | } 28 | --------------------------------------------------------------------------------