├── .dockerignore ├── .github ├── FUNDING.yml ├── stale.yml └── workflows │ └── build.yml ├── .gitignore ├── .rspec ├── .travis.yml ├── CHANGELOG.md ├── Dockerfile ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── acmesmith.gemspec ├── bin └── acmesmith ├── config.sample.yml ├── docs ├── challenge_responders │ └── route53.md ├── examples │ └── UpdateWindowsCertificate.ps1 ├── post_issuing_hooks │ ├── acm.md │ └── shell.md ├── storages │ ├── filesystem.md │ └── s3.md └── vendor │ └── aws.md ├── lib ├── acmesmith.rb └── acmesmith │ ├── account_key.rb │ ├── authorization_service.rb │ ├── certificate.rb │ ├── certificate_retrieving_service.rb │ ├── challenge_responder_filter.rb │ ├── challenge_responders.rb │ ├── challenge_responders │ ├── base.rb │ ├── manual_dns.rb │ ├── pebble_challtestsrv_dns.rb │ └── route53.rb │ ├── client.rb │ ├── command.rb │ ├── config.rb │ ├── domain_name_filter.rb │ ├── ordering_service.rb │ ├── post_issueing_hooks.rb │ ├── post_issueing_hooks │ └── base.rb │ ├── post_issuing_hooks.rb │ ├── post_issuing_hooks │ ├── acm.rb │ ├── base.rb │ └── shell.rb │ ├── save_certificate_service.rb │ ├── storages.rb │ ├── storages │ ├── base.rb │ ├── filesystem.rb │ └── s3.rb │ ├── utils │ └── finder.rb │ └── version.rb ├── script ├── console └── setup └── spec ├── account_key_spec.rb ├── certificate_retrieving_service_spec.rb ├── certificate_spec.rb ├── chain.pem ├── challenge_responders └── route53_spec.rb ├── integration └── pebble │ ├── integration_spec_config.yml │ └── pebble_spec.rb ├── leaf.pem ├── spec_helper.rb └── storages └── s3_spec.rb /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | pkg/ 3 | .bundle/ 4 | vendor/ 5 | acmesmith.yml 6 | config*yml 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: sorah 2 | github: [sorah] 3 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 30 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: rotten 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | schedule: 4 | - cron: '36 7 2,12,22 * *' 5 | create: {} 6 | pull_request: 7 | branches: [master] 8 | push: 9 | branches: [master, ci-test] 10 | 11 | env: 12 | DOCKER_REPO: 'sorah/acmesmith' 13 | 14 | jobs: 15 | test: 16 | name: rspec 17 | runs-on: ubuntu-latest 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | ruby-version: ['3.2', '3.3', '3.4'] 22 | steps: 23 | - uses: actions/checkout@master 24 | - uses: sorah-rbpkg/actions@v2 25 | with: 26 | ruby-version: "${{ matrix.ruby-version }}" 27 | bundler-cache: true 28 | - run: 'bundle exec rspec -fd' 29 | 30 | integration-pebble: 31 | name: integration-pebble 32 | runs-on: ubuntu-latest 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | ruby-version: ['3.2', '3.3', '3.4'] 37 | 38 | # FIXME: once GitHub Actions gains support of adding command line arguments to container 39 | # services: 40 | # # https://github.com/letsencrypt/pebble 41 | # pebble: 42 | # image: letsencrypt/pebble 43 | # ports: 44 | # - 14000:14000 # ACME port 45 | # - 15000:15000 # Management port 46 | # options: "pebble -config /test/config/pebble-config.json -strict -dnsserver 127.0.0.1:8053" 47 | # 48 | # challtestsrv: 49 | # image: letsencrypt/pebble-challtestsrv:latest 50 | # ports: 51 | # - 8055:8055 # HTTP Management API 52 | # - 8053:8053/udp # DNS 53 | # - 8053:8053 # DNS 54 | # options: 'pebble-challtestsrv -management :8055 -defaultIPv4 127.0.0.1' 55 | 56 | steps: 57 | - uses: actions/checkout@master 58 | 59 | - uses: sorah-rbpkg/actions@v2 60 | with: 61 | ruby-version: "${{ matrix.ruby-version }}" 62 | bundler-cache: true 63 | 64 | - run: 'docker run -d --net=host --rm letsencrypt/pebble pebble -config /test/config/pebble-config.json -strict -dnsserver 127.0.0.1:8053' 65 | - run: 'docker run -d --net=host --rm letsencrypt/pebble-challtestsrv pebble-challtestsrv -management :8055 -defaultIPv4 127.0.0.1' 66 | - run: 'bundle exec rspec -fd -t integration_pebble' 67 | 68 | docker-build: 69 | name: docker-build 70 | runs-on: ubuntu-latest 71 | steps: 72 | - uses: actions/checkout@master 73 | - run: 'echo $GITHUB_SHA > REVISION' 74 | 75 | - run: "docker pull ${DOCKER_REPO}:latest || :" 76 | - name: "docker tag ${DOCKER_REPO}:${TAG} ${DOCKER_REPO}:latest" 77 | run: | 78 | TAG=$(basename "${{ github.ref }}") 79 | docker pull ${DOCKER_REPO}:${TAG} || : 80 | docker tag ${DOCKER_REPO}:${TAG} ${DOCKER_REPO}:latest || : 81 | if: "${{ startsWith(github.ref, 'refs/tags/v') }}" 82 | 83 | - run: "docker pull ${DOCKER_REPO}:builder || :" 84 | 85 | - run: "docker build --pull --cache-from ${DOCKER_REPO}:builder --target builder -t ${DOCKER_REPO}:builder -f Dockerfile ." 86 | - run: "docker build --pull --cache-from ${DOCKER_REPO}:builder --cache-from ${DOCKER_REPO}:latest -t ${DOCKER_REPO}:${GITHUB_SHA} -f Dockerfile ." 87 | 88 | - run: "echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u sorah --password-stdin" 89 | if: "${{ github.event_name != 'pull_request' }}" 90 | 91 | - run: "docker push ${DOCKER_REPO}:builder" 92 | if: "${{ github.ref == 'refs/heads/master' }}" 93 | - run: "docker push ${DOCKER_REPO}:${GITHUB_SHA}" 94 | if: "${{ github.event_name != 'pull_request' }}" 95 | 96 | docker-push: 97 | name: docker-push 98 | needs: [test, integration-pebble, docker-build] 99 | if: "${{ github.event_name == 'push' || github.event_name == 'create' }}" 100 | runs-on: ubuntu-latest 101 | steps: 102 | - run: "echo ${{ secrets.DOCKERHUB_TOKEN }} | docker login -u sorah --password-stdin" 103 | - run: "docker pull ${DOCKER_REPO}:${GITHUB_SHA}" 104 | 105 | - run: | 106 | docker tag ${DOCKER_REPO}:${GITHUB_SHA} ${DOCKER_REPO}:latest 107 | docker push ${DOCKER_REPO}:latest 108 | if: "${{ github.ref == 'refs/heads/master' }}" 109 | - run: | 110 | TAG=$(basename "${{ github.ref }}") 111 | docker tag ${DOCKER_REPO}:${GITHUB_SHA} ${DOCKER_REPO}:${TAG} 112 | docker push ${DOCKER_REPO}:${TAG} 113 | if: "${{ startsWith(github.ref, 'refs/tags/v') }}" 114 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | acmesmith.yml 10 | config.yml 11 | config.dev.yml 12 | /storage/ 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.3.3 4 | - 2.4.0 5 | before_install: gem install bundler -v 1.10.6 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v2.7.0 (2025-04-28) 2 | 3 | ### Enhancements 4 | 5 | - autorenew: gains new option `--remaining-life` (`-r`) to specify threshold in ratio of remaining lifetime to total lifetime, e.g. `1/3`, `50%`. 6 | 7 | ### New behaviour 8 | 9 | - autonenew: in addition to above, the default option is now adjusted to `--reamining-life 1/3` instead of `--days 7`. This conforms to the Let's Encrypt recommendation to renew certificates when its remaining lifetime is less than 1/3 of the total lifetime. 10 | - docker: our provided Docker image now bundles rexml instead of nokogiri for aws-sdk-route53. 11 | 12 | 13 | ## v2.6.1 (2024-12-05) 14 | 15 | ### Fixes 16 | 17 | - route53: restore_to_original_records can have an error when querying existing record sets when it generates a name with leading empty labels (OTOH: double leading dots). [#65](https://github.com/sorah/acmesmith/pull/65) 18 | 19 | ## v2.6.0 (2023-10-05) 20 | 21 | ### Enhancement 22 | 23 | - order: Gains `--key-type`, `--rsa-key-size`, `--elliptic-curve` options to customize private key generation, and generating EC keys. [#58](https://github.com/sorah/acmesmith/pull/58) 24 | - autorenew: Respect the existing key configuration when regenerating a fresh key pair for renewal. [#58](https://github.com/sorah/acmesmith/pull/58) 25 | 26 | ## v2.5.0 (2020-10-09) 27 | 28 | ### Enhancement 29 | 30 | - Gains `chain_preferences` configuration to choose alternate chain. [#47](https://github.com/sorah/acmesmith/pull/47) 31 | - route53: Gains `substitution_map` to allow delegation of `_acme-challenge` via predefined CNAME record. [#53](https://github.com/sorah/acmesmith/pull/53) 32 | - s3: Gains `endpoint` option. [#52](https://github.com/sorah/acmesmith/pull/52) 33 | 34 | ## v2.4.0 (2020-12-03) 35 | 36 | ### Enhancement 37 | 38 | - route53: Gains `restore_to_original_records` option. When enabled, existing record will be restored after authorizing domain names. Useful when other ACME tools or providers using ACME where requires a certain record to remain as long as possible for their renewal process (e.g. Fastly TLS). 39 | 40 | ## v2.3.1 (2020-05-12) 41 | 42 | ### Fixes 43 | 44 | - Fixing Docker image build has failed for the release tag. https://github.com/sorah/acmesmith/runs/665853406 45 | 46 | ## v2.3.0 (2020-05-12) 47 | 48 | ### Enhancement 49 | 50 | - route53: Added support of assuming IAM Role to access Route 53. (requested at [#36](https://github.com/sorah/acmesmith/issues/36) [#37](https://github.com/sorah/acmesmith/pull/37) [#38](https://github.com/sorah/acmesmith/issues/36)) 51 | 52 | - Added filter for challenge responders. This allows selecting a challenge responder for specific domain names. (indirectly requested at [#36](https://github.com/sorah/acmesmith/issues/36) [#37](https://github.com/sorah/acmesmith/pull/37) [#38](https://github.com/sorah/acmesmith/issues/36)) 53 | 54 | ```yaml 55 | challenge_responders: 56 | # Use specific IAM role for the domain "example.dev" ... 57 | - route53: 58 | assume_role: 59 | role_arn: 'arn:aws:iam:...' 60 | filter: 61 | subject_name_exact: 62 | - example.dev 63 | 64 | - manual_dns: {} 65 | filter: 66 | subject_name_suffix: 67 | - example.net 68 | 69 | # Default 70 | - route53: {} 71 | ``` 72 | 73 | - config: now accepts `connection_options` and `bad_nonce_retry` for [`Acme::Client`](https://github.com/unixcharles/acme-client). 74 | 75 | ### Fixes 76 | 77 | - Exported PKCS#12 were not included a certificate chain [#35](https://github.com/sorah/acmesmith/pulls/35) 78 | - s3: `use_kms` option was not respected for certificate keys & PKCS#12. It was always `true`. 79 | - A large refactoring of internal components. 80 | 81 | ## v2.2.0 (2018-08-08) 82 | 83 | ### Enhancement 84 | 85 | - s3: Added `pkcs12_passphrase` and `pkcs12_commonname` options for saving PKCS#12 file into a S3 bucket. This is for scripts which read S3 bucket directly and needs PKCS#12 file. 86 | 87 | ## v2.1.0 (2018-06-07) 88 | 89 | ### Changes 90 | 91 | - route53: Private hosted zones are now ignored by default. If you really need to use such zones, specify explicitly with `hosted_zone_map`. 92 | 93 | ## v2.0.3 (2018-05-19) 94 | 95 | ### Bug fixes 96 | 97 | - `route53` couldn't create an appropriate RRSet when ACME server needs multiple authorizations for the single domain. [#31](https://github.com/sorah/acmesmith/issues/31) 98 | 99 | (In fact, responsing could fail when ordering certificate for `*.example.org` and `example.org` to LE.) 100 | 101 | ## v2.0.2 (2018-05-18) 102 | 103 | ### Bug fixes 104 | 105 | - `acm` post issuing hook could fail 106 | 107 | ## v2.0.1 (2018-05-18) 108 | 109 | ### Bug fixes 110 | 111 | - It could fail when encountered a challenge type which is unsupported by `acme-client` gem 112 | 113 | ## v2.0.0 (2018-05-18) 114 | 115 | ### Notable changes 116 | 117 | - Support ACME v2 118 | - Drop ACME v1 support 119 | - Challenge responder 120 | - New `dns-01` challenge responder `manual_dns` is bundled for manual DNS intervention. 121 | - New API to allow challenge responders to respond many challenges at once, for efficiency 122 | - Added its support to `route53` responder 123 | 124 | #### Compatibility note 125 | 126 | - `config['endpoint']` is removed. Use `config['directory']` to specify ACME v2 directory resource URL. 127 | - The deprecated `config['post_issueing_hook']` is removed as planned. 128 | 129 | ### CLI 130 | 131 | #### Compatibility note 132 | 133 | - Renamed several subcommands due to the changes in ACME (v2) semantics. 134 | 135 | - `acmesmith register` -> `acmesmith new-account` 136 | - `acmesmith request` -> `acmesmith order` 137 | 138 | The previous names remain working, but are now marked as deprecated. These will be removed in the future release. 139 | 140 | - Place warning for `acmesmith authorize` due to lack of implementation 141 | 142 | (At this moment, LE doesn't provide new-authz API) 143 | 144 | ### API and Internals 145 | 146 | (Interface of `Client` class is still in beta. It's designed to be an external API, but interface are still subject to change) 147 | 148 | #### Compatibility note 149 | 150 | - `config['endpoint']` is removed. Use `config['directory']` to specify ACME v2 directory resource URL. 151 | - Several renames due to the changes in ACME (v2) semantics. 152 | 153 | - `Client#register` -> `new_account` 154 | - `Client#request` -> `order` 155 | 156 | - Place warning for `Client#authorize` due to lack of implementation 157 | 158 | (At this moment, LE doesn't provide new-authz API) 159 | 160 | - `Certificate#chain` now returns `Array`. Use `Certificate#chain_pems` to retrieve in `String`. 161 | 162 | Note: Value for `:chain` key in a `Hash` returned by `Certificate#export` is kept `String` for Storages plugin compatibility. 163 | 164 | #### New Features 165 | 166 | - `ChallengeResponders::Base` now allows to respond many challenges at once. 167 | - Added `#respond_all` and `#cleanup_all()` method to respond many challenges. 168 | - Added `#cap_respond_all?` method to indicate a responder instance supports this capability or not. 169 | - Base class now implements `respond`, `cleanup` for classes which implement only the new `*_all` method. 170 | 171 | 172 | 173 | ## Prior versions 174 | 175 | See https://github.com/sorah/acmesmith/releases 176 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM sorah/ruby:3.4-dev as builder 2 | 3 | #RUN apt-get update \ 4 | # && apt-get install -y libmysqlclient-dev git-core \ 5 | # && rm -rf /var/lib/apt/lists/* 6 | 7 | WORKDIR /app 8 | COPY Gemfile /app/ 9 | COPY Gemfile.lock /app/ 10 | COPY acmesmith.gemspec /app/ 11 | RUN sed -i -e 's|Acmesmith::VERSION|"0.0.0"|g' -e '/^require.*acmesmith.version/d' -e '/`git/d' acmesmith.gemspec 12 | 13 | RUN bundle install --path /gems --jobs 100 --without development 14 | 15 | FROM sorah/ruby:3.4 16 | 17 | #RUN apt-get update \ 18 | # && apt-get install -y libmysqlclient20 \ 19 | # && rm -rf /var/lib/apt/lists/* 20 | 21 | WORKDIR /app 22 | COPY . /app/ 23 | COPY --from=builder /gems /gems 24 | COPY --from=builder /app/.bundle /app/.bundle 25 | COPY --from=builder /app/Gemfile* /app/ 26 | COPY --from=builder /app/acmesmith.gemspec /app/ 27 | 28 | ENTRYPOINT ["bundle", "exec", "bin/acmesmith"] 29 | 30 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in acmesmith.gemspec 4 | gemspec 5 | 6 | gem 'rexml' 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | acmesmith (2.7.0) 5 | acme-client (>= 2.0.7, < 3) 6 | aws-sdk-acm 7 | aws-sdk-route53 8 | aws-sdk-s3 9 | thor 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | acme-client (2.0.19) 15 | base64 (~> 0.2.0) 16 | faraday (>= 1.0, < 3.0.0) 17 | faraday-retry (>= 1.0, < 3.0.0) 18 | aws-eventstream (1.3.0) 19 | aws-partitions (1.1018.0) 20 | aws-sdk-acm (1.81.0) 21 | aws-sdk-core (~> 3, >= 3.210.0) 22 | aws-sigv4 (~> 1.5) 23 | aws-sdk-core (3.214.0) 24 | aws-eventstream (~> 1, >= 1.3.0) 25 | aws-partitions (~> 1, >= 1.992.0) 26 | aws-sigv4 (~> 1.9) 27 | jmespath (~> 1, >= 1.6.1) 28 | aws-sdk-kms (1.96.0) 29 | aws-sdk-core (~> 3, >= 3.210.0) 30 | aws-sigv4 (~> 1.5) 31 | aws-sdk-route53 (1.105.0) 32 | aws-sdk-core (~> 3, >= 3.210.0) 33 | aws-sigv4 (~> 1.5) 34 | aws-sdk-s3 (1.176.0) 35 | aws-sdk-core (~> 3, >= 3.210.0) 36 | aws-sdk-kms (~> 1) 37 | aws-sigv4 (~> 1.5) 38 | aws-sigv4 (1.10.1) 39 | aws-eventstream (~> 1, >= 1.0.2) 40 | base64 (0.2.0) 41 | diff-lcs (1.5.1) 42 | faraday (2.12.1) 43 | faraday-net_http (>= 2.0, < 3.5) 44 | json 45 | logger 46 | faraday-net_http (3.4.0) 47 | net-http (>= 0.5.0) 48 | faraday-retry (2.2.1) 49 | faraday (~> 2.0) 50 | jmespath (1.6.2) 51 | json (2.9.0) 52 | logger (1.6.2) 53 | net-http (0.6.0) 54 | uri 55 | rake (13.2.1) 56 | rexml (3.4.1) 57 | rspec (3.13.0) 58 | rspec-core (~> 3.13.0) 59 | rspec-expectations (~> 3.13.0) 60 | rspec-mocks (~> 3.13.0) 61 | rspec-core (3.13.2) 62 | rspec-support (~> 3.13.0) 63 | rspec-expectations (3.13.3) 64 | diff-lcs (>= 1.2.0, < 2.0) 65 | rspec-support (~> 3.13.0) 66 | rspec-mocks (3.13.2) 67 | diff-lcs (>= 1.2.0, < 2.0) 68 | rspec-support (~> 3.13.0) 69 | rspec-support (3.13.2) 70 | thor (1.3.2) 71 | uri (1.0.3) 72 | 73 | PLATFORMS 74 | ruby 75 | 76 | DEPENDENCIES 77 | acmesmith! 78 | bundler 79 | rake 80 | rexml 81 | rspec 82 | 83 | BUNDLED WITH 84 | 2.5.23 85 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Sorah Fukumori 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Acmesmith: A simple, effective ACME v2 client to use with many servers and a cloud 2 | 3 | ![ci](https://github.com/sorah/acmesmith/workflows/ci/badge.svg?event=push) Buy Me a Coffee at ko-fi.com 4 | 5 | 6 | Acmesmith is an [ACME (Automatic Certificate Management Environment)](https://github.com/ietf-wg-acme/acme) client that works perfect on environment with multiple servers. This client saves certificate and keys on cloud services (e.g. AWS S3) securely, then allow to deploy issued certificates onto your servers smoothly. This works well on [Let's encrypt](https://letsencrypt.org). 7 | 8 | This tool is written in Ruby, but Acmesmith saves certificates in simple scheme, so you can fetch certificate by your own simple scripts. 9 | 10 | ## Features 11 | 12 | - ACME v2 client designed to work on multiple servers 13 | - ACME registration, domain authorization, certificate requests 14 | - Tested against [Let's encrypt](https://letsencrypt.org) 15 | - Storing keys in several ways 16 | - Challenge response 17 | - Many cloud services support 18 | - AWS S3 storage and Route 53 `dns-01` responder support out-of-the-box 19 | - 3rd party plugins available for OpenStack designate, Google Cloud DNS, simple http-01, and Google Cloud Storage. See [Plugins](#3rd-party-plugins) below 20 | 21 | ## Installation 22 | 23 | Add this line to your application's Gemfile: 24 | 25 | ```ruby 26 | gem 'acmesmith' 27 | ``` 28 | 29 | And then execute: 30 | 31 | $ bundle 32 | 33 | Or install it yourself as: 34 | 35 | $ gem install acmesmith 36 | 37 | ### Docker 38 | 39 | ``` 40 | docker run -v /path/to/acmesmith.yml:/app/acmesmith.yml:ro sorah/acmesmith:latest 41 | ``` 42 | 43 | [`Dockerfile`](./Dockerfile) is available. Default confguration file is at `/app/acmesmith.yml`. 44 | 45 | Pre-built docker images are provided at https://hub.docker.com/r/sorah/acmesmith for your convenience 46 | Built with GitHub Actions & [sorah-rbpkg/dockerfiles](https://github.com/sorah-rbpkg/dockerfiles). 47 | 48 | ## Usage 49 | 50 | ``` 51 | $ acmesmith new-account CONTACT # Create account key (contact e.g. mailto:xxx@example.org) 52 | ``` 53 | 54 | ``` 55 | $ acmesmith order COMMON_NAME [SAN] # request certificate for CN +COMMON_NAME+ with SANs +SAN+ 56 | $ acmesmith add-san COMMON_NAME [SAN] # re-request existing certificate of CN with additional SAN(s) 57 | ``` 58 | 59 | ``` 60 | $ acmesmith list [COMMON_NAME] # list certificates or its versions 61 | $ acmesmith current COMMON_NAME # show current version for certificate 62 | $ acmesmith show-certificate COMMON_NAME # show certificate 63 | $ acmesmith show-private-key COMMON_NAME # show private key 64 | $ acmesmith save-certificate COMMON_NAME --output=PATH # Save certificate to a file 65 | $ acmesmith save-private-key COMMON_NAME --output=PATH # Save private key to a file 66 | $ acmesmith save-pkcs12 COMMON_NAME --output=PATH # Save certificate and private key to a PKCS12 file 67 | ``` 68 | 69 | ``` 70 | $ acmesmith autorenew [-r RATIO] [-d DAYS] # Renew certificates which being expired soon. Default to -r 1/3 71 | ``` 72 | 73 | ``` 74 | # Save (or update) certificate files and key in a one command 75 | $ acmesmith save COMMON_NAME \ 76 | --version-file=/tmp/cert.txt # Path to save a certificate version for following run 77 | --key-file=/tmp/cert.key # Path to save a key 78 | --fullchain-file=/tmp/cert.pem # Path to save a certficiate and its chain (concatenated) 79 | ``` 80 | 81 | See `acmesmith help [subcommand]` for more help. 82 | 83 | ## Configuration 84 | 85 | See [config.sample.yml](./config.sample.yml) to start. Default configuration file is `./acmesmith.yml`. 86 | 87 | ``` yaml 88 | directory: https://acme-v02.api.letsencrypt.org/directory # production 89 | 90 | storage: 91 | # configure where to store keys and certificates; described later 92 | type: s3 93 | region: 'us-east-1' 94 | bucket: 'my-acmesmith-bucket' 95 | prefix: 'prod/' 96 | 97 | challenge_responders: 98 | # configure how to respond ACME challenges; described later 99 | - route53: {} 100 | ``` 101 | 102 | ### Storage 103 | 104 | Storage provider stores issued certificates, private keys and ACME account keys. 105 | 106 | - Amazon S3: [s3](./docs/storages/s3.md) 107 | - Filesystem: [filesystem](./docs/storages/filesystem.md) 108 | - Google Cloud Storage: [minimum2scp/acmesmith-google-cloud-storage](https://github.com/minimum2scp/acmesmith-google-cloud-storage) _(plugin)_ 109 | 110 | ### Challenge Responders 111 | 112 | Challenge responders responds to ACME challenges to prove domain ownership to CA. 113 | 114 | - API driven 115 | - AWS Route 53: [route53](./docs/challenge_responders/route53.md) (`dns-01`) 116 | - Google Cloud DNS: [nagachika/acmesmith-google-cloud-dns](https://github.com/nagachika/acmesmith-google-cloud-dns) (`dns-01`, _plugin_ ) 117 | - OpenStack Designate v1: [hanazuki/acmesmith-designate](https://github.com/hanazuki/acmesmith-designate) (`dns-01`, _plugin_ ) 118 | - Verisign MDNS REST API: [benkap/acmesmith-verisign](https://github.com/benkap/acmesmith-verisign) (`dns-01`, _plugin_ ) 119 | - Generic 120 | - Static HTTP: [mipmip/acmesmith-http-path](https://github.com/mipmip/acmesmith-http-path) (`http-01`, _plugin_ ) 121 | 122 | #### Common options 123 | 124 | ```yaml 125 | challenge_responders: 126 | ## Multiple responders are accepted. 127 | ## The first responder that supports a challenge and applicable for given domain name will be used. 128 | - {RESPONDER_TYPE}: 129 | {RESPONDER_OPTIONS} 130 | 131 | ### Filter (optional) 132 | filter: 133 | subject_name_exact: 134 | - my-app.example.com 135 | subject_name_suffix: 136 | - .example.org 137 | subject_name_regexp: 138 | - '\Aapp\d+.example.org\z' 139 | 140 | - {RESPONDER_TYPE}: 141 | {RESPONDER_OPTIONS} 142 | ... 143 | ``` 144 | 145 | ### Post Issuing Hooks 146 | 147 | Post issuing hooks are configurable actions that are executed 148 | when a new certificate has been succesfully issued. The hooks are 149 | sequentially executed in the same order as they are configured, and they 150 | are configurable per certificate's common-name. 151 | 152 | - Shell script: [shell](./docs/post_issuing_hooks/shell.md) 153 | - Amazon Certificate Manager (ACM): [acm](./docs/post_issuing_hooks/acm.md) 154 | 155 | ### Chain preference 156 | 157 | If you want to prefer an alternative chain given by CA ([RFC8555 Section 7.4.2.](https://datatracker.ietf.org/doc/html/rfc8555#section-7.4.2)), use the following configuration. Preference may be delcared with common name. 158 | 159 | When chain preferences are configured for the common name of an ordered certificate, Acmesmith will retrieve all available alternative chains and evaluate rules in an configured order. The first chain matched to a rule will be used and saved to a storage. 160 | 161 | During rule evaluation, a root issuer name and key id are taken from the last available intermediate (Issuer and AKI) provided in a chain, when a chain doesn't have a root certificate (trust anchor). 162 | 163 | ```yaml 164 | chain_preferences: 165 | - root_issuer_name: "ISRG Root X1" 166 | ### Optionally, you may specify CA SKI/AKI: 167 | # root_issuer_key_id: "79:b4:59:e6:7b:b6:e5:e4:01:73:80:08:88:c8:1a:58:f6:e9:9b:6e" 168 | 169 | ### Filter by common name (optional) 170 | filter: 171 | exact: 172 | - my-app.example.com 173 | suffix: 174 | - .example.org 175 | regexp: 176 | - '\Aapp\d+.example.org\z' 177 | ``` 178 | 179 | ## Vendor dependent notes 180 | 181 | - [./docs/vendor/aws.md](./docs/vendor/aws.md): IAM and KMS key policies, and some tips 182 | 183 | ## Contributing 184 | 185 | Bug reports and pull requests are welcome on GitHub at https://github.com/sorah/acmesmith. 186 | 187 | ### Running tests 188 | 189 | unit test: 190 | 191 | ``` 192 | bundle exec rspec 193 | ``` 194 | 195 | integration test using [letsencrypt/pebble](https://github.com/letsencrypt/pebble). needs Docker: 196 | 197 | ``` 198 | ACMESMITH_CI_START_PEBBLE=1 CI=1 bundle exec -t integration_pebble 199 | ``` 200 | 201 | ## Writing plugins 202 | 203 | Publish as a gem (RubyGems). Files will be loaded automatically from `lib/acmesmith/{plugin_type}/{name}.rb`. 204 | 205 | e.g. 206 | 207 | - storage: `lib/acmesmith/storages/perfect_storage.rb` & `Acmesmith::Storages::PerfectStorage` 208 | - challenge_responder: `lib/acmesmith/challenge_responders/perfect_authority.rb` & `Acmesmith::Storages::PerfectAuthority` 209 | - post_issuing_hook: `lib/acmesmith/challenge_responders/nice_deploy.rb` & `Acmesmith::Storages::NiceDeploy` 210 | 211 | ## Development 212 | 213 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 214 | 215 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 216 | 217 | 218 | 219 | ## License 220 | 221 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 222 | 223 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task :default => :spec 7 | -------------------------------------------------------------------------------- /acmesmith.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'acmesmith/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "acmesmith" 8 | spec.version = Acmesmith::VERSION 9 | spec.authors = ["Sorah Fukumori"] 10 | spec.email = ["her@sorah.jp"] 11 | 12 | spec.summary = %q{ACME client (Let's encrypt client) to manage certificate in multi server environment with cloud services (e.g. AWS)} 13 | spec.description = <<-EOF 14 | Acmesmith is an [ACME (Automatic Certificate Management Environment)](https://github.com/ietf-wg-acme/acme) client that works perfect on environment with multiple servers. This client saves certificate and keys on cloud services (e.g. AWS S3) securely, then allow to deploy issued certificates onto your servers smoothly. This works well on [Let's encrypt](https://letsencrypt.org). 15 | EOF 16 | spec.homepage = "https://github.com/sorah/acmesmith" 17 | spec.license = "MIT" 18 | 19 | spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 20 | spec.bindir = "bin" 21 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 22 | spec.require_paths = ["lib"] 23 | 24 | spec.add_dependency "acme-client", '>= 2.0.7', '< 3' 25 | spec.add_dependency "aws-sdk-acm" 26 | spec.add_dependency "aws-sdk-route53" 27 | spec.add_dependency "aws-sdk-s3" 28 | spec.add_dependency "thor" 29 | 30 | spec.add_development_dependency "bundler" 31 | spec.add_development_dependency "rake" 32 | spec.add_development_dependency "rspec" 33 | end 34 | -------------------------------------------------------------------------------- /bin/acmesmith: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'acmesmith/command' 3 | 4 | Acmesmith::Command.start 5 | -------------------------------------------------------------------------------- /config.sample.yml: -------------------------------------------------------------------------------- 1 | ### 2 | ### ACME Endpoint Configuration 3 | ### 4 | 5 | directory: https://acme-staging-v02.api.letsencrypt.org/directory 6 | # directory: https://acme-v02.api.letsencrypt.org/directory 7 | 8 | ### 9 | ### Storage 10 | ### 11 | 12 | # Check README and ./docs/storages/* for details 13 | storage: 14 | type: s3 15 | region: 'ap-northeast-1' 16 | bucket: '...' 17 | # prefix: '...' 18 | # kms_key_id: 'arn:aws:kms:...' 19 | 20 | # storage: 21 | # type: filesystem 22 | # path: ./storage 23 | 24 | ### 25 | ### Challenge responders 26 | ### 27 | 28 | # Check README and ./docs/challenge_responders/* for details 29 | challenge_responders: 30 | # Use dns_manual for subjects under ".manual-domain.example.org" 31 | - dns_manual: {} 32 | filter: 33 | subject_name_suffix: 34 | - .manual-domain.example.org 35 | # subject_name_exact: 36 | # subject_name_regexp: 37 | 38 | # Last resort 39 | - route53: {} 40 | 41 | ### 42 | ### advanced options 43 | ### 44 | 45 | ## Passphrase to encrypt key files (optional) 46 | # account_key_passphrase: password 47 | # certificate_key_passphrase: secret 48 | 49 | ## Instead, read passphrases from $ACMESMITH_ACCOUNT_KEY_PASSPHRASE, $ACMESMITH_CERTIFICATE_KEY_PASSPHRASE 50 | # passphrase_from_env: true 51 | 52 | ## ACME connection options (Faraday) 53 | # connection_options: 54 | # :tls: 55 | # :ca_file: custom_ca_bundle.pem 56 | 57 | ## acme-client bad_nonce_retry 58 | # bad_nonce_retry: 2 59 | -------------------------------------------------------------------------------- /docs/challenge_responders/route53.md: -------------------------------------------------------------------------------- 1 | # Challenge Responder: Route 53 2 | 3 | `route53` responder supports `dns-01` challenge type. This assumes domain NS are managed under Route53 hosted zone. 4 | 5 | ```yaml 6 | challenge_responders: 7 | - route53: 8 | ### AWS Access key (optional, default to aws-sdk standard) 9 | aws_access_key: 10 | access_key_id: 11 | secret_access_key: 12 | # session_token: 13 | 14 | ### Assume IAM role to access Route 53 15 | # Available options are https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/AssumeRoleCredentials.html 16 | assume_role: 17 | role_arn: "arn:aws:iam:::..." 18 | 19 | ### Hosted zone map (optional) 20 | ## This specifies an exact hosted zone ID for each domain name. 21 | ## Required when you have multiple hosted zones for the same domain name. 22 | hosted_zone_map: 23 | "example.org.": "/hostedzone/DEADBEEF" 24 | 25 | # Restore to original records on cleanup (after domain authorization). Default to false. 26 | # Useful when you need to keep existing record as long as possible. 27 | restore_to_original_records: false 28 | 29 | ### Substitution record names map (optional) 30 | ## This specifies alias for specific _acme-challenge record. For instance the following example 31 | ## updates _acme-challenge.test-example-com.example.org instead of _acme-challenge.test.example.com. 32 | ## 33 | ## This eases using the route53 responder for domains not managed in route53, by registering CNAME record to 34 | ## the alias record name on the original record name in advance. This is called delegation. 35 | substitution_map: 36 | "test.example.com.": "test-example-com.example.org." 37 | ``` 38 | 39 | ## IAM Policy 40 | 41 | - [docs/vendor/aws.md](../vendor/aws.md): IAM and KMS key policies, and some tips 42 | -------------------------------------------------------------------------------- /docs/examples/UpdateWindowsCertificate.ps1: -------------------------------------------------------------------------------- 1 | #Requires -RunAsAdministrator 2 | Set-StrictMode -Version Latest 3 | $ErrorActionPreference = "Stop" 4 | $PSDefaultParameterValues['*:ErrorAction']='Stop' 5 | 6 | Set-Location -Path cert:\LocalMachine\My 7 | 8 | $region = "ap-northeast-1" 9 | $bucket = "your-s3-bucket" 10 | $prefix = "certs/" 11 | $cn = "example.com" 12 | $pfxPassword = ConvertTo-SecureString -String "your-p12-password" -AsPlainText -Force 13 | $lastCertificateMarkFile = "C:\AcmesmithCert.txt" 14 | 15 | if (Test-Path $lastCertificateMarkFile) { 16 | $lastCertificate = Get-ChildItem -Path (Get-Content -Path $lastCertificateMarkFile) 17 | } else { 18 | $lastCertificate = $null 19 | } 20 | 21 | Import-Module AWSPowershell 22 | 23 | # In powershell 4.x or earlier 24 | # [System.IO.FileInfo]([System.IO.Path]::GetTempFileName()) 25 | $currentFile = New-TemporaryFile 26 | Read-S3Object -Region $region -BucketName $bucket -Key ("{0}{1}/current" -f $prefix,$cn) -File $currentFile 27 | $current = Get-Content $currentFile 28 | 29 | $pfxKey = "{0}{1}/{2}/cert.p12" -f $prefix,$cn,$current 30 | $chainKey = "{0}{1}/{2}/chain.pem" -f $prefix,$cn,$current 31 | 32 | $pfxFile = New-TemporaryFile 33 | Read-S3Object -Region $region -BucketName $bucket -Key $pfxKey -File $pfxFile 34 | 35 | $chainFile = New-TemporaryFile 36 | Read-S3Object -Region $region -BucketName $bucket -Key $chainKey -File $chainFile 37 | 38 | $cert = Import-PfxCertificate -Password $pfxPassword -FilePath $pfxFile.FullName -CertStoreLocation 'cert:\LocalMachine\My' 39 | Remove-Item $pfxFile 40 | 41 | $intermediate = Import-Certificate -FilePath $chainFile.FullName -CertStoreLocation 'cert:\LocalMachine\CA' 42 | Write-Output $intermediate 43 | Write-Output $cert 44 | 45 | if ($lastCertificate) { 46 | Write-Output "Switching" 47 | if ($lastCertificate.Thumbprint -ne $cert.Thumbprint) { 48 | Switch-Certificate -OldCert $lastCertificate -NewCert $cert 49 | } 50 | } 51 | $cert.Thumbprint | Out-File $lastCertificateMarkFile 52 | 53 | $expiredCerts = Get-ChildItem -Path 'Cert:\LocalMachine\My' -SSLServerAuthentication -ExpiringInDays 0 -DnsName $cert.DnsNameList[0].Unicode 54 | $expiredCerts | Remove-Item -DeleteKey 55 | 56 | ## http.sys 57 | $appid = "{existing-uuid}" # or "{{{0}}}" -f [GUID]::NewGuid().Guid 58 | netsh http update sslcert ipport=0.0.0.0:443 ("certhash={0}" -f $cert.Thumbprint) ("appid={0}" -f $appid) 59 | -------------------------------------------------------------------------------- /docs/post_issuing_hooks/acm.md: -------------------------------------------------------------------------------- 1 | # Post issuing hook: Amazon Certificate Manager 2 | 3 | `acm` imports certificate into AWS ACM. 4 | 5 | ```yaml 6 | post_issuing_hooks: 7 | "test.example.com": 8 | - acm: 9 | region: us-east-1 # required 10 | certificate_arn: arn:aws:acm:... # (optional) 11 | ``` 12 | 13 | When `certificate_arn` is not present, `acm` hook attempts to find the certificate ARN from existing certificate list. Certificate with same common name ("domain name" on ACM), and `Acmesmith` tag 14 | will be used. Otherwise, `acm` hook imports as a new certificate with `Acmesmith` tag. 15 | 16 | 17 | -------------------------------------------------------------------------------- /docs/post_issuing_hooks/shell.md: -------------------------------------------------------------------------------- 1 | # Post Issuing Hook: Shell 2 | 3 | Execute specified command on a shell. Environment variable `${COMMON_NAME}` is available. 4 | 5 | ```yaml 6 | post_issuing_hooks: 7 | "test.example.com": 8 | - shell: 9 | command: mail -s "New cert for ${COMMON_NAME} has been issued" user@example.com < /dev/null 10 | - shell: 11 | command: touch /tmp/certs-has-been-issued-${COMMON_NAME} 12 | "admin.example.com": 13 | - shell: 14 | command: /usr/bin/dosomethingelse ${COMMON_NAME} 15 | ``` 16 | 17 | 18 | -------------------------------------------------------------------------------- /docs/storages/filesystem.md: -------------------------------------------------------------------------------- 1 | # Storage: Filesystem 2 | 3 | This is not recommended for production use. If you're planning to use this, make sure backing up the keys. 4 | 5 | ```yaml 6 | storage: 7 | type: filesystem 8 | path: /path/to/directory/to/store/keys 9 | ``` 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/storages/s3.md: -------------------------------------------------------------------------------- 1 | # Storage: S3 2 | 3 | ```yaml 4 | storage: 5 | type: s3 6 | region: 7 | bucket: 8 | # prefix: 9 | # aws_access_key: # aws credentials (optional); If omit, default configuration of aws-sdk use will be used. 10 | # access_key_id: 11 | # secret_access_key: 12 | # session_token: 13 | # use_kms: true 14 | # kms_key_id: # KMS key id (optional); if omit, default AWS managed key for S3 will be used 15 | # kms_key_id_account: # KMS key id for account key (optional); This overrides kms_key_id 16 | # kms_key_id_certificate_key: # KMS key id for private keys for certificates (optional); This oveerides kms_key_id 17 | # pkcs12_passphrase: # (optional) Set passphrase to generate PKCS#12 file (for scripts that reads S3 bucket directly) 18 | # pkcs12_common_names: ['example.org'] # (optional) List of common names to limit certificates for generating PKCS#12 file. 19 | ``` 20 | 21 | This saves certificates and keys in the following S3 keys: 22 | 23 | - `{prefix}/account.pem`: Account private key in pem 24 | - `{prefix}/certs/{common_name}/current`: text file contains current version name 25 | - `{prefix}/certs/{common_name}/{version}/cert.pem`: certificate in pem 26 | - `{prefix}/certs/{common_name}/{version}/key.pem`: private key in pem 27 | - `{prefix}/certs/{common_name}/{version}/chain.pem`: CA chain in pem 28 | - `{prefix}/certs/{common_name}/{version}/fullchain.pem`: certificate + CA chain in pem. This is suitable for some server softwares like nginx. 29 | 30 | ## IAM/KMS Policy 31 | 32 | - [docs/vendor/aws.md](../vendor/aws.md): IAM and KMS key policies, and some tips 33 | -------------------------------------------------------------------------------- /docs/vendor/aws.md: -------------------------------------------------------------------------------- 1 | #### IAM policy 2 | 3 | ##### All access (S3 + Route53 setup) 4 | 5 | ``` json 6 | { 7 | "Version": "2012-10-17", 8 | "Statement": [ 9 | { 10 | "Effect": "Allow", 11 | "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"], 12 | "Resource": ["arn:aws:s3:::{BUCKET-NAME}", "arn:aws:s3:::{BUCKET-NAME}/*"] 13 | }, 14 | { 15 | "Effect": "Allow", 16 | "Action": ["route53:ListHostedZones", "route53:GetChange"], 17 | "Resource": "*" 18 | }, 19 | { 20 | "Effect": "Allow", 21 | "Action": "route53:ChangeResourceRecordSets", 22 | "Resource": ["arn:aws:route53:::hostedzone/*"] 23 | }, 24 | { 25 | "Effect": "Allow", 26 | "Action": "route53:ListResourceRecordSets", 27 | "Resource": ["arn:aws:route53:::hostedzone/*"] 28 | } 29 | ] 30 | } 31 | ``` 32 | 33 | - You can limit allowed hosted zone by `Resource` of `route53:ChangeResourceRecordSets` grant 34 | - `route53:ListResourceRecordSets` will be only required when `restore_to_original_records` is set 35 | 36 | ##### Only fetching certificates 37 | 38 | ``` json 39 | { 40 | "Version": "2012-10-17", 41 | "Statement": [ 42 | { 43 | "Effect": "Allow", 44 | "Action": ["s3:GetObject"], 45 | "Resource": ["arn:aws:s3:::{BUCKET-NAME}/certs/*"] 46 | }, 47 | { 48 | "Effect": "Allow", 49 | "Action": ["s3:ListBucket"], 50 | "Resource": ["arn:aws:s3:::{BUCKET-NAME}"], 51 | "Condition": { 52 | "StringEquals": { 53 | "s3:delimiter": "/" 54 | }, 55 | "StringLike": { 56 | "s3:prefix": "certs/*" 57 | } 58 | } 59 | } 60 | ] 61 | } 62 | ``` 63 | 64 | #### AWS KMS key policy for customer managed keys 65 | 66 | If you're going to use `aws_kms_id` option to use customer managed keys instead of AWS managed default KMS key for Amazon S3, use the following policy as base: 67 | 68 | Be sure to replace `{S3-REGION}` and `{YOUR-AWS-ACCOUNT-ID}` before applying it. 69 | 70 | ``` json 71 | { 72 | "Version": "2012-10-17", 73 | "Id": "kms-acmesmith-s3-policy", 74 | "Statement": [ 75 | { 76 | "Effect": "Allow", 77 | "Principal": { 78 | "AWS": "*" 79 | }, 80 | "Action": [ 81 | "kms:Encrypt", 82 | "kms:Decrypt", 83 | "kms:ReEncrypt*", 84 | "kms:GenerateDataKey*", 85 | "kms:DescribeKey" 86 | ], 87 | "Resource": "*", 88 | "Condition": { 89 | "StringEquals": { 90 | "kms:ViaService": "s3.{S3-REGION}.amazonaws.com", 91 | "kms:CallerAccount": "{YOUR-AWS-ACCOUNT-ID}" 92 | } 93 | } 94 | }, 95 | { 96 | "Effect": "Allow", 97 | "Principal": { 98 | "AWS": "arn:aws:iam::{YOUR-AWS-ACCOUNT-ID}:root" 99 | }, 100 | "Action": [ 101 | "kms:Describe*", 102 | "kms:Get*", 103 | "kms:List*", 104 | "kms:Put*" 105 | ], 106 | "Resource": "*" 107 | } 108 | ] 109 | } 110 | ``` 111 | 112 | #### Policy for ACM post issuing hook 113 | 114 | ``` json 115 | { 116 | "Version": "2012-10-17", 117 | "Statement": [ 118 | { 119 | "Effect": "Allow", 120 | "Action": ["acm:ImportCertificate", "acm:AddTagsToCertificate"], 121 | "Resource": ["*"] 122 | } 123 | ] 124 | } 125 | ``` 126 | 127 | Optionally you can limit resource to certificate ARN(s). 128 | 129 | -------------------------------------------------------------------------------- /lib/acmesmith.rb: -------------------------------------------------------------------------------- 1 | require "acmesmith/version" 2 | 3 | module Acmesmith 4 | # Your code goes here... 5 | end 6 | -------------------------------------------------------------------------------- /lib/acmesmith/account_key.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | 3 | module Acmesmith 4 | class AccountKey 5 | class PassphraseRequired < StandardError; end 6 | class PrivateKeyDecrypted < StandardError; end 7 | 8 | # @param bit_length [Integer] 9 | # @return [Acmesmith::AccountKey] 10 | def self.generate(bit_length = 2048) 11 | new OpenSSL::PKey::RSA.new(bit_length) 12 | end 13 | 14 | # @param private_key [String, OpenSSL::PKey::RSA] 15 | # @param passphrase [String, nil] 16 | def initialize(private_key, passphrase = nil) 17 | case private_key 18 | when String 19 | @raw_private_key = private_key 20 | if passphrase 21 | self.key_passphrase = passphrase 22 | else 23 | begin 24 | @private_key = OpenSSL::PKey::RSA.new(@raw_private_key) { nil } 25 | rescue OpenSSL::PKey::RSAError 26 | # may be encrypted 27 | end 28 | end 29 | when OpenSSL::PKey::RSA 30 | @private_key = private_key 31 | else 32 | raise TypeError, 'private_key is expected to be a String or OpenSSL::PKey::RSA' 33 | end 34 | end 35 | 36 | # Try to decrypt private_key if encrypted. 37 | # @param pw [String] passphrase for encrypted PEM 38 | # @raise [PrivateKeyDecrypted] if private_key is decrypted 39 | def key_passphrase=(pw) 40 | raise PrivateKeyDecrypted, 'private_key already given' if @private_key 41 | 42 | @private_key = OpenSSL::PKey::RSA.new(@raw_private_key, pw) 43 | 44 | @raw_private_key = nil 45 | nil 46 | end 47 | 48 | # @return [OpenSSL::PKey::RSA] 49 | # @raise [PassphraseRequired] if private_key is not yet decrypted 50 | def private_key 51 | return @private_key if @private_key 52 | raise PassphraseRequired, 'key_passphrase required' 53 | end 54 | 55 | # @return [String] PEM 56 | def export(passphrase, cipher: OpenSSL::Cipher.new('aes-256-cbc')) 57 | if passphrase 58 | private_key.export(cipher, passphrase) 59 | else 60 | private_key.export 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/acmesmith/authorization_service.rb: -------------------------------------------------------------------------------- 1 | module Acmesmith 2 | class AuthorizationService 3 | class NoApplicableChallengeResponder < StandardError; end 4 | class AuthorizationFailed < StandardError; end 5 | 6 | # @!attribute [r] domain 7 | # @return [String] domain name 8 | # @!attribute [r] authorization 9 | # @return [Acme::Client::Resources::Authorization] authz object 10 | # @!attribute [r] challenge_responder 11 | # @return [Acmesmith::ChallengeResponders::Base] responder 12 | # @!attribute [r] challenge 13 | # @return [Acme::Client::Resources::Challenges::Base] challenge 14 | AuthorizationProcess = Struct.new(:domain, :authorization, :challenge_responder, :challenge, keyword_init: true) do 15 | def completed? 16 | invalid? || valid? 17 | end 18 | 19 | def invalid? 20 | challenge.status == 'invalid' 21 | end 22 | 23 | def valid? 24 | challenge.status == 'valid' 25 | end 26 | 27 | def responder_id 28 | challenge_responder.__id__ 29 | end 30 | end 31 | 32 | # @param challenge_responder_rules [Array] 33 | # @param authorizations [Array] 34 | def initialize(challenge_responder_rules, authorizations) 35 | @challenge_responder_rules = challenge_responder_rules 36 | @authorizations = authorizations 37 | end 38 | 39 | attr_reader :challenge_responder_rules, :authorizations 40 | 41 | def perform! 42 | return if authorizations.empty? 43 | 44 | respond() 45 | request_validation() 46 | wait_for_validation() 47 | cleanup() 48 | 49 | puts "=> Authorized!" 50 | end 51 | 52 | def respond 53 | processes_by_responder.each do |responder, ps| 54 | puts "=> Responsing to the challenges for the following identifier:" 55 | puts 56 | puts " * Responder: #{responder.class}" 57 | puts " * Identifiers:" 58 | 59 | ps.each do |process| 60 | puts " - #{process.domain} (#{process.challenge.challenge_type})" 61 | end 62 | 63 | puts 64 | responder.respond_all(*ps.map{ |t| [t.domain, t.challenge] }) 65 | end 66 | end 67 | 68 | def request_validation 69 | puts "=> Requesting validations..." 70 | puts 71 | processes.each do |process| 72 | challenge = process.challenge 73 | print " * #{process.domain} (#{challenge.challenge_type}) ..." 74 | retried = false 75 | begin 76 | challenge.request_validation() 77 | puts " [ ok ]" 78 | rescue Acme::Client::Error::Malformed 79 | # Rescue in case of requesting validation for a challenge which has already determined valid (asynchronously while we're receiving it). 80 | # LE Boulder doesn't take this as an error, but pebble do. 81 | # https://github.com/letsencrypt/boulder/blob/ebba443cad233111ee2b769ef09b32a13c3ba57e/wfe2/wfe.go#L1235 82 | # https://github.com/letsencrypt/pebble/blob/b60b0b677c280ccbf63de55a26775591935c448b/wfe/wfe.go#L2166 83 | challenge.reload 84 | if process.valid? 85 | puts " [ ok ] (turned valid in background)" 86 | next 87 | end 88 | 89 | if retried 90 | raise 91 | else 92 | retried = true 93 | retry 94 | end 95 | end 96 | end 97 | puts 98 | 99 | end 100 | 101 | def wait_for_validation 102 | puts "=> Waiting for the validation..." 103 | puts 104 | 105 | loop do 106 | processes.each do |process| 107 | next if process.valid? 108 | 109 | process.challenge.reload 110 | 111 | status = process.challenge.status 112 | puts " * [#{process.domain}] status: #{status}" 113 | 114 | case 115 | when process.valid? 116 | next 117 | when process.invalid? 118 | err = process[:challenge].error 119 | puts " ! [#{process[:domain]}] error: #{err.inspect}" 120 | end 121 | end 122 | break if processes.all?(&:completed?) 123 | sleep 3 124 | end 125 | 126 | puts 127 | 128 | invalid_processes = processes.select(&:invalid?) 129 | unless invalid_processes.empty? 130 | $stderr.puts "" 131 | $stderr.puts "!! Some identitiers failed to challenge" 132 | $stderr.puts "" 133 | invalid_processes.each do |process| 134 | $stderr.puts " - #{process.domain}: #{process.challenge.error.inspect}" 135 | end 136 | $stderr.puts "" 137 | raise AuthorizationFailed, "Some identifiers failed to challenge: #{invalid_processes.map(&:domain).inspect}" 138 | end 139 | 140 | end 141 | 142 | def cleanup 143 | processes_by_responder.each do |responder, ps| 144 | puts "=> Cleaning the responses the challenges for the following identifier:" 145 | puts 146 | puts " * Responder: #{responder.class}" 147 | puts " * Identifiers:" 148 | ps.each do |process| 149 | puts " - #{process.domain} (#{process.challenge.challenge_type})" 150 | end 151 | puts 152 | 153 | responder.cleanup_all(*ps.map{ |t| [t.domain, t.challenge] }) 154 | end 155 | end 156 | 157 | # @return [Array] 158 | def processes 159 | @processes ||= authorizations.map do |authz| 160 | challenge = nil 161 | responder_rule = challenge_responder_rules.select do |rule| 162 | rule.filter.applicable?(authz.domain) 163 | end.find do |rule| 164 | challenge = authz.challenges.find do |c| 165 | # OMG, acme-client might return a Hash instead of Acme::Client::Resources::Challenge::* object... 166 | challenge_type = case 167 | when c.is_a?(Hash) 168 | c[:challenge_type] 169 | when c.is_a?(Acme::Client::Resources::Challenges::Unsupported) 170 | next 171 | when c.respond_to?(:challenge_type) 172 | c.challenge_type 173 | end 174 | rule.challenge_responder.support?(challenge_type) 175 | end 176 | end 177 | 178 | unless responder_rule 179 | raise NoApplicableChallengeResponder, "Cannot find a challenge responder for domain #{authz.domain.inspect}" 180 | end 181 | 182 | AuthorizationProcess.new( 183 | domain: authz.domain, 184 | authorization: authz, 185 | challenge_responder: responder_rule.challenge_responder, 186 | challenge: challenge, 187 | ) 188 | end 189 | end 190 | 191 | # @return [Array<(Acmesmith::ChallengeResponders::Base, Array)>] 192 | def processes_by_responder 193 | @processes_by_responder ||= processes.group_by(&:responder_id).map { |_, ps| [ps[0].challenge_responder, ps] } 194 | end 195 | end 196 | end 197 | -------------------------------------------------------------------------------- /lib/acmesmith/certificate.rb: -------------------------------------------------------------------------------- 1 | require 'openssl' 2 | 3 | module Acmesmith 4 | class Certificate 5 | class PrivateKeyDecrypted < StandardError; end 6 | class PassphraseRequired < StandardError; end 7 | 8 | CertificateExport = Struct.new(:certificate, :chain, :fullchain, :private_key, keyword_init: true) 9 | 10 | # Split string containing multiple PEMs into Array of PEM strings. 11 | # @param [String] 12 | # @return [Array] 13 | def self.split_pems(pems) 14 | pems.each_line.slice_before(/^-----BEGIN CERTIFICATE-----$/).map(&:join) 15 | end 16 | 17 | # Return Acmesmith::Certificate by an issued certificate 18 | # @param pem_chain [String] 19 | # @param csr [Acme::Client::CertificateRequest] 20 | # @return [Acmesmith::Certificate] 21 | def self.by_issuance(pem_chain, csr) 22 | pems = split_pems(pem_chain) 23 | new(pems[0], pems[1..-1], csr.private_key, nil, csr) 24 | end 25 | 26 | # @param certificate [OpenSSL::X509::Certificate, String] 27 | # @param chain [String, Array, Array] 28 | # @param private_key [String, OpenSSL::PKey::PKey] 29 | # @param key_passphrase [String, nil] 30 | # @param csr [String, OpenSSL::X509::Request, nil] 31 | def initialize(certificate, chain, private_key, key_passphrase = nil, csr = nil) 32 | @certificate = case certificate 33 | when OpenSSL::X509::Certificate 34 | certificate 35 | when String 36 | OpenSSL::X509::Certificate.new(certificate) 37 | else 38 | raise TypeError, 'certificate is expected to be a String or OpenSSL::X509::Certificate' 39 | end 40 | chain = case chain 41 | when String 42 | self.class.split_pems(chain) 43 | when Array 44 | chain 45 | when nil 46 | [] 47 | else 48 | raise TypeError, 'chain is expected to be an Array or nil' 49 | end 50 | 51 | @chain = chain.map { |cert| 52 | case cert 53 | when OpenSSL::X509::Certificate 54 | cert 55 | when String 56 | OpenSSL::X509::Certificate.new(cert) 57 | else 58 | raise TypeError, 'chain is expected to be an Array or nil' 59 | end 60 | } 61 | 62 | case private_key 63 | when String 64 | @raw_private_key = private_key 65 | if key_passphrase 66 | self.key_passphrase = key_passphrase 67 | else 68 | begin 69 | @private_key = OpenSSL::PKey.read(@raw_private_key) { nil } 70 | rescue OpenSSL::PKey::PKeyError 71 | # may be encrypted 72 | end 73 | end 74 | when OpenSSL::PKey::PKey 75 | @private_key = private_key 76 | else 77 | raise TypeError, 'private_key is expected to be a String or OpenSSL::PKey::PKey' 78 | end 79 | 80 | @csr = case csr 81 | when nil 82 | nil 83 | when String 84 | OpenSSL::X509::Request.new(csr) 85 | when OpenSSL::X509::Request 86 | csr 87 | end 88 | end 89 | 90 | # @return [OpenSSL::X509::Certificate] 91 | attr_reader :certificate 92 | # @return [Array] 93 | attr_reader :chain 94 | # @return [OpenSSL::X509::Request] 95 | attr_reader :csr 96 | 97 | # Try to decrypt private_key if encrypted. 98 | # @param pw [String] passphrase for encrypted PEM 99 | # @raise [PrivateKeyDecrypted] if private_key is decrypted 100 | def key_passphrase=(pw) 101 | raise PrivateKeyDecrypted, 'private_key already given' if @private_key 102 | 103 | @private_key = OpenSSL::PKey.read(@raw_private_key, pw) 104 | 105 | @raw_private_key = nil 106 | nil 107 | end 108 | 109 | # @return [OpenSSL::PKey::PKey] 110 | # @raise [PassphraseRequired] if private_key is not yet decrypted 111 | def private_key 112 | return @private_key if @private_key 113 | raise PassphraseRequired, 'key_passphrase required' 114 | end 115 | 116 | # @return [OpenSSL::PKey::PKey] 117 | def public_key 118 | @certificate.public_key 119 | end 120 | 121 | # @return [String] leaf certificate + full certificate chain 122 | def fullchain 123 | "#{certificate.to_pem}\n#{issuer_pems}".gsub(/\n+/,?\n) 124 | end 125 | 126 | # @return [String] issuer certificate chain 127 | def issuer_pems 128 | chain.map(&:to_pem).join("\n") 129 | end 130 | 131 | # @return [String] common name 132 | def common_name 133 | certificate.subject.to_a.assoc('CN')[1] 134 | end 135 | 136 | # @return [Array] Subject Alternative Names (dNSname) 137 | def sans 138 | certificate.extensions.select { |_| _.oid == 'subjectAltName' }.flat_map do |ext| 139 | ext.value.split(/,\s*/).select { |_| _.start_with?('DNS:') }.map { |_| _[4..-1] } 140 | end 141 | end 142 | 143 | # @return [String] Version string (consists of NotBefore time & certificate serial) 144 | def version 145 | "#{certificate.not_before.utc.strftime('%Y%m%d-%H%M%S')}_#{certificate.serial.to_i.to_s(16)}" 146 | end 147 | 148 | # @return [OpenSSL::PKCS12] 149 | def pkcs12(passphrase) 150 | OpenSSL::PKCS12.create(passphrase, common_name, private_key, certificate, chain) 151 | end 152 | 153 | # @return [CertificateExport] 154 | def export(passphrase, cipher: OpenSSL::Cipher.new('aes-256-cbc')) 155 | CertificateExport.new.tap do |h| 156 | h.certificate = certificate.to_pem 157 | h.chain = issuer_pems 158 | h.fullchain = fullchain 159 | h.private_key = if passphrase 160 | private_key.export(cipher, passphrase) 161 | else 162 | private_key.export 163 | end 164 | end 165 | end 166 | end 167 | end 168 | -------------------------------------------------------------------------------- /lib/acmesmith/certificate_retrieving_service.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/certificate' 2 | 3 | module Acmesmith 4 | class CertificateRetrievingService 5 | # @param acme [Acme::Client] 6 | # @param common_name [String] 7 | # @param url [String] ACME Certificate URL 8 | # @param chain_preferences [Array] 9 | def initialize(acme, common_name, url, chain_preferences: []) 10 | @acme = acme 11 | @url = url 12 | @chain_preferences = chain_preferences.select { |_| _.filter.match?(common_name) } 13 | end 14 | 15 | attr_reader :acme 16 | attr_reader :url 17 | attr_reader :chain_preferences 18 | 19 | def pem_chain 20 | response = download(url, format: :pem) 21 | pem = response.body 22 | 23 | return pem if chain_preferences.empty? 24 | 25 | puts " * Retrieving all chains..." 26 | alternative_urls = Array(response.headers.dig('link', 'alternate')) 27 | alternative_chains = alternative_urls.map { |_| CertificateChain.new(download(_, format: :pem).body) } 28 | 29 | chains = [CertificateChain.new(pem), *alternative_chains] 30 | 31 | chains.each_with_index do |chain, i| 32 | puts " #{i.succ}. #{chain.to_s}" 33 | end 34 | puts 35 | 36 | chain_preferences.each do |rule| 37 | chains.each_with_index do |chain, i| 38 | if chain.match?(name: rule.root_issuer_name, key_id: rule.root_issuer_key_id) 39 | puts " * Chain chosen: ##{i.succ}" 40 | return chain.pem_chain 41 | end 42 | end 43 | end 44 | 45 | warn " ! Preferred chain is not available, chain chosen: #1" 46 | chains.first.pem_chain 47 | end 48 | 49 | class CertificateChain 50 | def initialize(pem_chain) 51 | @pem_chain = pem_chain 52 | @pems = Certificate.split_pems(pem_chain) 53 | @certificates = @pems.map { |_| OpenSSL::X509::Certificate.new(_) } 54 | end 55 | 56 | attr_reader :pem_chain 57 | attr_reader :certificates 58 | 59 | def to_s 60 | certificates[1..-1].map do |c| 61 | "s:#{c.subject},i:#{c.issuer}" 62 | end.join(" | ") 63 | end 64 | 65 | def match?(name: nil, key_id: nil) 66 | has_root = top.issuer == top.subject 67 | 68 | if name 69 | return false unless name == (has_root ? top.subject : top.issuer).to_a.assoc('CN')[1] 70 | end 71 | 72 | if key_id 73 | top_key_id = if has_root 74 | value_der(top.extensions.find { |e| e.oid == 'subjectKeyIdentifier' })&.slice(2..-1) 75 | else 76 | value_der(top.extensions.find { |e| e.oid == 'authorityKeyIdentifier' })&.slice(4,20) 77 | end&.unpack1('H*')&.downcase 78 | return false unless key_id.downcase.gsub(/:/,'') == top_key_id 79 | end 80 | 81 | true 82 | end 83 | 84 | def top 85 | @top ||= find_top() 86 | end 87 | 88 | private def find_top 89 | c = certificates.first 90 | while c 91 | up = find_issuer(c) 92 | return c unless up 93 | c = up 94 | end 95 | end 96 | 97 | private def find_issuer(cert) 98 | return nil if cert.issuer == cert.subject 99 | 100 | # https://datatracker.ietf.org/doc/html/rfc5280#section-4.2.1.1 101 | # sequence(\x30\x16) context-specific(\x80\x14) + keyid 102 | aki = value_der(cert.extensions.find { |e| e.oid == 'authorityKeyIdentifier' }) 103 | 104 | # compare using SKI as a AKI DER. this doesn't support AKI using other than keyid but it should be okay 105 | certificates.find do |c| 106 | ski_der = value_der(c.extensions.find { |e| e.oid == 'subjectKeyIdentifier' }) 107 | next unless ski_der 108 | hdr = "\x30\x16\x80\x14".b 109 | keyid = ski_der[2..-1] 110 | 111 | "#{hdr}#{keyid}" == aki && cert.issuer == c.subject 112 | end 113 | end 114 | 115 | private def value_der(ext) 116 | return nil unless ext 117 | ext.respond_to?(:value_der) ? ext.value_der : ext.to_der[9..-1] 118 | end 119 | end 120 | 121 | private def download(url, format:) 122 | # XXX: Use of private API https://github.com/unixcharles/acme-client/blob/5990b3105569a9d791ea011e0c5e57506eb54353/lib/acme/client.rb#L311 123 | acme.__send__(:download, url, format: format) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /lib/acmesmith/challenge_responder_filter.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/domain_name_filter' 2 | 3 | module Acmesmith 4 | class ChallengeResponderFilter 5 | def initialize(responder, subject_name_exact: nil, subject_name_suffix: nil, subject_name_regexp: nil) 6 | @responder = responder 7 | @domain_name_filter = DomainNameFilter.new( 8 | exact: subject_name_exact, 9 | suffix: subject_name_suffix, 10 | regexp: subject_name_regexp, 11 | ) 12 | end 13 | 14 | def applicable?(domain) 15 | @domain_name_filter.match?(domain) && @responder.applicable?(domain) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/acmesmith/challenge_responders.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/utils/finder' 2 | 3 | module Acmesmith 4 | module ChallengeResponders 5 | def self.find(name) 6 | Utils::Finder.find(self, 'acmesmith/challenge_responders', name) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/acmesmith/challenge_responders/base.rb: -------------------------------------------------------------------------------- 1 | module Acmesmith 2 | module ChallengeResponders 3 | class Base 4 | # @param type [String] ACME challenge type (dns-01, http-01, ...) 5 | # @return [true, false] true when given challenge type is supported 6 | def support?(type) 7 | raise NotImplementedError 8 | end 9 | 10 | # @param domain [String] target FQDN for a ACME authorization challenge 11 | # @return [true, false] true when a responder is able to challenge against given domain name 12 | def applicable?(domain) 13 | true 14 | end 15 | 16 | # @return [true, false] true when implements #respond_all, #cleanup_all 17 | def cap_respond_all? 18 | false 19 | end 20 | 21 | def initialize() 22 | end 23 | 24 | # Respond to the given challenges (1 or more). 25 | # @param domain_and_challenges [Array<(String, Acme::Client::Resources::Challenges::Base)>] array of tuple of domain name and ACME challenge 26 | def respond_all(*domain_and_challenges) 27 | if cap_respond_all? 28 | raise NotImplementedError 29 | else 30 | domain_and_challenges.each do |dc| 31 | respond(*dc) 32 | end 33 | end 34 | end 35 | 36 | # Clean up responses for the given challenges (1 or more). 37 | # @param domain_and_challenges [Array<(String, Acme::Client::Resources::Challenges::Base)>] array of tuple of domain name and ACME challenge 38 | def cleanup_all(*domain_and_challenges) 39 | if cap_respond_all? 40 | raise NotImplementedError 41 | else 42 | domain_and_challenges.each do |dc| 43 | cleanup(*dc) 44 | end 45 | end 46 | end 47 | 48 | # If cap_respond_all? is true, you don't need to implement this method. 49 | def respond(domain, challenge) 50 | if cap_respond_all? 51 | respond_all([domain, challenge]) 52 | else 53 | raise NotImplementedError 54 | end 55 | end 56 | 57 | # If cap_respond_all? is true, you don't need to implement this method. 58 | def cleanup(domain, challenge) 59 | if cap_respond_all? 60 | cleanup_all([domain, challenge]) 61 | else 62 | raise NotImplementedError 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/acmesmith/challenge_responders/manual_dns.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/challenge_responders/base' 2 | 3 | module Acmesmith 4 | module ChallengeResponders 5 | class ManualDns < Base 6 | class HostedZoneNotFound < StandardError; end 7 | class AmbiguousHostedZones < StandardError; end 8 | 9 | def support?(type) 10 | # Acme::Client::Resources::Challenges::DNS01 11 | type == 'dns-01' 12 | end 13 | 14 | def initialize(options={}) 15 | end 16 | 17 | def respond(domain, challenge) 18 | puts "=> Responding challenge dns-01 for #{domain}" 19 | puts 20 | 21 | domain = canonical_fqdn(domain) 22 | record_name = "#{challenge.record_name}.#{domain}" 23 | record_type = challenge.record_type 24 | record_content = "\"#{challenge.record_content}\"" 25 | 26 | puts "#{record_name}. 5 IN #{record_type} #{record_content}" 27 | 28 | puts "(Hit enter when DNS record get ready)" 29 | $stdin.gets 30 | end 31 | 32 | def cleanup(domain, challenge) 33 | domain = canonical_fqdn(domain) 34 | record_name = "#{challenge.record_name}.#{domain}" 35 | puts "=> It's now okay to delete DNS record for #{record_name}" 36 | end 37 | 38 | private 39 | 40 | def canonical_fqdn(domain) 41 | "#{domain}.".sub(/\.+$/, '') 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/acmesmith/challenge_responders/pebble_challtestsrv_dns.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/challenge_responders/base' 2 | require 'net/http' 3 | require 'uri' 4 | require 'json' 5 | 6 | module Acmesmith 7 | module ChallengeResponders 8 | class PebbleChalltestsrvDns < Base 9 | def support?(type) 10 | # Acme::Client::Resources::Challenges::DNS01 11 | type == 'dns-01' 12 | end 13 | 14 | def initialize(url: 'http://localhost:8055') 15 | warn_test 16 | @url = URI.parse(url) 17 | end 18 | 19 | attr_reader :url 20 | 21 | def respond(domain, challenge) 22 | warn_test 23 | 24 | Net::HTTP.post( 25 | URI.join(url,"/set-txt"), 26 | { 27 | host: "#{challenge.record_name}.#{domain}.", 28 | value: challenge.record_content, 29 | }.to_json, 30 | ).value 31 | end 32 | 33 | def cleanup(domain, challenge) 34 | warn_test 35 | 36 | Net::HTTP.post( 37 | URI.join(url,"/clear-txt"), 38 | { 39 | host: "#{challenge.record_name}.#{domain}.", 40 | }.to_json, 41 | ).value 42 | end 43 | 44 | def warn_test 45 | unless ENV['CI'] 46 | $stderr.puts '!!!!!!!!! WARNING WARNING WARNING !!!!!!!!!' 47 | $stderr.puts '!!!! pebble-challtestsrv command is for TEST USAGE ONLY. It is trivially insecure, offering no authentication. Only use pebble-challtestsrv in a controlled test environment.' 48 | $stderr.puts '!!!! https://github.com/letsencrypt/pebble/blob/master/cmd/pebble-challtestsrv/README.md' 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/acmesmith/challenge_responders/route53.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/challenge_responders/base' 2 | 3 | require 'aws-sdk-route53' 4 | 5 | module Acmesmith 6 | module ChallengeResponders 7 | class Route53 < Base 8 | class HostedZoneNotFound < StandardError; end 9 | class AmbiguousHostedZones < StandardError; end 10 | 11 | def support?(type) 12 | # Acme::Client::Resources::Challenges::DNS01 13 | type == 'dns-01' 14 | end 15 | 16 | def cap_respond_all? 17 | true 18 | end 19 | 20 | def initialize(aws_access_key: nil, assume_role: nil, hosted_zone_map: {}, restore_to_original_records: false, substitution_map: {}) 21 | aws_options = {region: 'us-east-1'}.tap do |opt| 22 | opt[:credentials] = Aws::Credentials.new(aws_access_key['access_key_id'], aws_access_key['secret_access_key'], aws_access_key['session_token']) if aws_access_key 23 | end 24 | 25 | @route53 = Aws::Route53::Client.new(aws_options.dup.tap do |opt| 26 | case 27 | when assume_role 28 | opt[:credentials] = Aws::AssumeRoleCredentials.new( 29 | client: Aws::STS::Client.new(aws_options), 30 | **({role_session_name: "acmesmith-#{$$}"}.merge(assume_role.map{ |k,v| [k.to_sym,v] }.to_h)), 31 | ) 32 | end 33 | end) 34 | 35 | @hosted_zone_map = hosted_zone_map 36 | @hosted_zone_cache = {} 37 | 38 | @restore_to_original_records = restore_to_original_records 39 | @original_records = {} 40 | 41 | @substitution_map = substitution_map.map { |k,v| [canonical_fqdn(k), v] }.to_h 42 | end 43 | 44 | def respond_all(*domain_and_challenges) 45 | domain_and_challenges = apply_substitution_for_domain_and_challenges(domain_and_challenges) 46 | 47 | save_original_records(*domain_and_challenges) if @restore_to_original_records 48 | 49 | challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) } 50 | 51 | zone_and_batches = challenges_by_hosted_zone.map do |zone_id, dcs| 52 | [ 53 | zone_id, 54 | change_batch_for_challenges( 55 | dcs, 56 | action: 'UPSERT', 57 | pre_changes: changes_to_delete_original_cname(zone_id, *dcs), 58 | ), 59 | ] 60 | end 61 | 62 | change_ids = request_changing_rrset(zone_and_batches, comment: 'for challenge response') 63 | wait_for_sync(change_ids) 64 | end 65 | 66 | def cleanup_all(*domain_and_challenges) 67 | domain_and_challenges = apply_substitution_for_domain_and_challenges(domain_and_challenges) 68 | 69 | challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) } 70 | 71 | zone_and_batches = challenges_by_hosted_zone.map do |zone_id, dcs| 72 | [ 73 | zone_id, 74 | change_batch_for_challenges( 75 | dcs, 76 | action: 'DELETE', 77 | comment: '(cleanup)', 78 | post_changes: changes_to_restore_original_records(zone_id, *dcs), 79 | ), 80 | ] 81 | end 82 | 83 | request_changing_rrset(zone_and_batches, comment: 'to remove challenge responses') 84 | end 85 | 86 | private 87 | 88 | def save_original_records(*domain_and_challenges) 89 | domain_and_challenges.each do |domain, challenge| 90 | 91 | hosted_zone_id = find_hosted_zone(domain) 92 | name = "#{challenge.record_name}.#{canonical_fqdn(domain)}." 93 | 94 | rrsets = list_existing_rrsets(hosted_zone_id, name) 95 | next if rrsets.empty? 96 | 97 | @original_records[hosted_zone_id] ||= {} 98 | @original_records[hosted_zone_id][name] = rrsets 99 | puts " * original_record: #{domain}(#{hosted_zone_id}): #{rrsets.inspect}" 100 | 101 | end 102 | end 103 | 104 | def changes_to_delete_original_cname(zone_id, *domain_and_challenges) 105 | @original_records[zone_id] ||= {} 106 | domain_and_challenges.map do |domain, challenge| 107 | name = "#{challenge.record_name}.#{domain}." 108 | original_records = @original_records[zone_id][name] 109 | next unless original_records 110 | original_cname = original_records.find{ |_| _.type == 'CNAME' } 111 | next unless original_cname 112 | 113 | # FIXME: support set_identifier? 114 | { 115 | action: 'DELETE', 116 | resource_record_set: { 117 | name: original_cname.name, 118 | ttl: original_cname.ttl, 119 | type: original_cname.type, 120 | resource_records: original_cname.resource_records.map(&:to_h), 121 | alias_target: original_cname.alias_target&.to_h, 122 | }, 123 | } 124 | end.compact 125 | end 126 | 127 | def changes_to_restore_original_records(zone_id, *domain_and_challenges) 128 | @original_records[zone_id] ||= {} 129 | domain_and_challenges.flat_map do |domain, challenge| 130 | name = "#{challenge.record_name}.#{domain}." 131 | original_records = @original_records[zone_id][name] 132 | next unless original_records 133 | 134 | # FIXME: support set_identifier? 135 | original_records.map do |original_record| 136 | next if original_record.type != challenge.record_type && original_record.type != 'CNAME' 137 | { 138 | action: 'CREATE', 139 | resource_record_set: { 140 | name: original_record.name, 141 | ttl: original_record.ttl, 142 | type: original_record.type, 143 | resource_records: original_record.resource_records.map(&:to_h), 144 | alias_target: original_record.alias_target&.to_h, 145 | }, 146 | } 147 | end 148 | end.compact 149 | end 150 | 151 | def request_changing_rrset(zone_and_batches, comment: nil) 152 | puts "=> Requesting RRSet change #{comment}" 153 | puts 154 | change_ids = zone_and_batches.map do |(zone_id, change_batch)| 155 | puts " * #{zone_id}:" 156 | change_batch.fetch(:changes).each do |b| 157 | rrset = b.fetch(:resource_record_set) 158 | rrset.fetch(:resource_records).each do |rr| 159 | puts " - #{b.fetch(:action)}: #{rrset.fetch(:name)} #{rrset.fetch(:ttl)} #{rrset.fetch(:type)} #{rr.fetch(:value)}" 160 | end 161 | end 162 | print " ... " 163 | 164 | resp = @route53.change_resource_record_sets( 165 | hosted_zone_id: zone_id, # required 166 | change_batch: change_batch, 167 | ) 168 | change_id = resp.change_info.id 169 | 170 | puts "[ ok ] #{change_id}" 171 | puts 172 | change_id 173 | end 174 | 175 | change_ids 176 | end 177 | 178 | 179 | def wait_for_sync(change_ids) 180 | puts "=> Waiting for change to be in sync" 181 | puts 182 | 183 | all_sync = false 184 | until all_sync 185 | sleep 4 186 | 187 | all_sync = true 188 | change_ids.each do |id| 189 | change = @route53.get_change(id: id) 190 | 191 | sync = change.change_info.status == 'INSYNC' 192 | all_sync = false unless sync 193 | 194 | puts " * #{id}: #{change.change_info.status}" 195 | sleep 0.2 196 | end 197 | end 198 | puts 199 | end 200 | 201 | def change_batch_for_challenges(domain_and_challenges, comment: nil, action: 'UPSERT', pre_changes: [], post_changes: []) 202 | changes = domain_and_challenges 203 | .map do |d, c| 204 | rrset_for_challenge(d, c) 205 | end 206 | .group_by do |_| 207 | # Reduce changes by name. ACME server may require multiple challenge responses for the same identifier 208 | _.fetch(:name) 209 | end 210 | .map do |name, cs| 211 | cs.inject { |result, change| 212 | result.merge(resource_records: result.fetch(:resource_records, []) + change.fetch(:resource_records)) 213 | } 214 | end 215 | .map do |change| 216 | { 217 | action: action, 218 | resource_record_set: change, 219 | } 220 | end 221 | 222 | { 223 | comment: "ACME challenge response #{comment}", 224 | changes: pre_changes + changes + post_changes, 225 | } 226 | end 227 | 228 | def rrset_for_challenge(domain, challenge) 229 | domain = canonical_fqdn(domain) 230 | { 231 | name: "#{challenge.record_name}.#{domain}", 232 | type: challenge.record_type, 233 | ttl: 5, 234 | resource_records: [ 235 | value: "\"#{challenge.record_content}\"", 236 | ], 237 | } 238 | end 239 | 240 | def canonical_fqdn(domain) 241 | "#{domain}.".sub(/\.+$/, '') 242 | end 243 | 244 | def find_hosted_zone(domain) 245 | labels = domain.split(?.) 246 | zones = nil 247 | 0.upto(labels.size-1).each do |i| 248 | zones = hosted_zone_list["#{labels[i .. -1].join(?.)}."] 249 | break if zones 250 | end 251 | 252 | raise HostedZoneNotFound, "hosted zone not found for #{domain.inspect}" unless zones 253 | raise AmbiguousHostedZones, "multiple hosted zones found for #{domain.inspect}: #{zones.inspect}, set @hosted_zone_map to identify" if zones.size != 1 254 | zones.first 255 | end 256 | 257 | def hosted_zone_map 258 | @hosted_zone_map.map { |domain, zone_id| 259 | ["#{canonical_fqdn(domain)}.", [zone_id]] # XXX: 260 | }.to_h 261 | end 262 | 263 | def hosted_zone_list 264 | @hosted_zone_list ||= begin 265 | @route53.list_hosted_zones.each.flat_map do |page| 266 | page.hosted_zones 267 | .reject { |zone| zone.config.private_zone } 268 | .map { |zone| [zone.name, zone.id] } 269 | end.group_by(&:first).map { |domain, kvs| [domain, kvs.map(&:last)] }.to_h.merge(hosted_zone_map) 270 | end 271 | end 272 | 273 | def apply_substitution_for_domain_and_challenges(domain_and_challenges) 274 | domain_and_challenges.map { |(domain, challenge)| [@substitution_map.fetch(canonical_fqdn(domain), domain), challenge] } 275 | end 276 | 277 | def list_existing_rrsets(hosted_zone_id, name) 278 | rrsets = [] 279 | start_record_name = name 280 | start_record_type = nil 281 | start_record_identifier = nil 282 | 283 | while start_record_name == name 284 | begin 285 | tries = 0 286 | page = @route53.list_resource_record_sets( 287 | hosted_zone_id: hosted_zone_id, 288 | start_record_name: start_record_name, 289 | start_record_type: start_record_type, 290 | start_record_identifier: start_record_identifier, 291 | max_items: 10, 292 | ) 293 | page.resource_record_sets.each do |rrset| 294 | rrsets << rrset if rrset.name == name 295 | end 296 | 297 | start_record_name = page.next_record_name 298 | start_record_type = page.next_record_type 299 | start_record_identifier = page.next_record_identifier 300 | rescue Aws::Route53::Errors::Throttling => e 301 | interval = (2**tries) * 0.1 302 | $stderr.puts " ! #{e.class}: Sleeping #{interval} seconds (#{e.message})" 303 | sleep interval 304 | tries += 1 305 | retry 306 | end 307 | end 308 | rrsets 309 | end 310 | end 311 | end 312 | end 313 | -------------------------------------------------------------------------------- /lib/acmesmith/client.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/account_key' 2 | require 'acmesmith/certificate' 3 | require 'acmesmith/authorization_service' 4 | require 'acmesmith/ordering_service' 5 | require 'acmesmith/save_certificate_service' 6 | require 'acme-client' 7 | 8 | module Acmesmith 9 | class Client 10 | def initialize(config: nil) 11 | @config ||= config 12 | end 13 | 14 | def new_account(contact, tos_agreed: true) 15 | key = AccountKey.generate 16 | acme = Acme::Client.new(private_key: key.private_key, directory: config.directory, connection_options: config.connection_options, bad_nonce_retry: config.bad_nonce_retry) 17 | acme.new_account(contact: contact, terms_of_service_agreed: tos_agreed) 18 | 19 | storage.put_account_key(key, account_key_passphrase) 20 | 21 | key 22 | end 23 | 24 | def order(*identifiers, key_type: 'rsa', rsa_key_size: 2048, elliptic_curve: 'prime256v1', not_before: nil, not_after: nil) 25 | private_key = generate_private_key(key_type: key_type, rsa_key_size: rsa_key_size, elliptic_curve: elliptic_curve) 26 | order_with_private_key(*identifiers, private_key: private_key, not_before: not_before, not_after: not_after) 27 | end 28 | 29 | def authorize(*identifiers) 30 | raise NotImplementedError, "Domain authorization in advance is still not available in acme-client (v2). Required authorizations will be performed when ordering certificates" 31 | end 32 | 33 | def post_issue_hooks(common_name) 34 | cert = storage.get_certificate(common_name) 35 | execute_post_issue_hooks(cert) 36 | end 37 | 38 | def execute_post_issue_hooks(certificate) 39 | hooks = config.post_issuing_hooks(certificate.common_name) 40 | return if hooks.empty? 41 | puts "=> Executing post issuing hooks for CN=#{certificate.common_name}" 42 | hooks.each do |hook| 43 | hook.run(certificate: certificate) 44 | end 45 | puts 46 | end 47 | 48 | def certificate_versions(common_name) 49 | storage.list_certificate_versions(common_name).sort 50 | end 51 | 52 | def certificates_list 53 | storage.list_certificates.sort 54 | end 55 | 56 | def current(common_name) 57 | storage.get_current_certificate_version(common_name) 58 | end 59 | 60 | def get_certificate(common_name, version: 'current', type: 'text') 61 | cert = storage.get_certificate(common_name, version: version) 62 | 63 | certs = [] 64 | case type 65 | when 'text' 66 | certs << cert.certificate.to_text 67 | certs << cert.certificate.to_pem 68 | when 'certificate' 69 | certs << cert.certificate.to_pem 70 | when 'chain' 71 | certs << cert.chain 72 | when 'fullchain' 73 | certs << cert.fullchain 74 | end 75 | 76 | certs 77 | end 78 | 79 | def save_certificate(common_name, version: 'current', mode: '0600', output:, type: 'fullchain') 80 | cert = storage.get_certificate(common_name, version: version) 81 | File.open(output, 'w', mode.to_i(8)) do |f| 82 | case type 83 | when 'certificate' 84 | f.puts cert.certificate.to_pem 85 | when 'chain' 86 | f.puts cert.chain 87 | when 'fullchain' 88 | f.puts cert.fullchain 89 | end 90 | end 91 | end 92 | 93 | def get_private_key(common_name, version: 'current') 94 | cert = storage.get_certificate(common_name, version: version) 95 | cert.key_passphrase = certificate_key_passphrase if certificate_key_passphrase 96 | 97 | cert.private_key.to_pem 98 | end 99 | 100 | def save_private_key(common_name, version: 'current', mode: '0600', output:) 101 | cert = storage.get_certificate(common_name, version: version) 102 | cert.key_passphrase = certificate_key_passphrase if certificate_key_passphrase 103 | File.open(output, 'w', mode.to_i(8)) do |f| 104 | f.puts(cert.private_key) 105 | end 106 | end 107 | 108 | def save_pkcs12(common_name, version: 'current', mode: '0600', output:, passphrase:) 109 | cert = storage.get_certificate(common_name, version: version) 110 | cert.key_passphrase = certificate_key_passphrase if certificate_key_passphrase 111 | 112 | p12 = cert.pkcs12(passphrase) 113 | File.open(output, 'w', mode.to_i(8)) do |f| 114 | f.puts p12.to_der 115 | end 116 | end 117 | 118 | def save(common_name, version: 'current', **kwargs) 119 | cert = storage.get_certificate(common_name, version: version) 120 | cert.key_passphrase = certificate_key_passphrase if certificate_key_passphrase 121 | 122 | SaveCertificateService.new(cert, **kwargs).perform! 123 | end 124 | 125 | def autorenew(days: 30, remaining_life: nil, common_names: nil) 126 | (common_names || storage.list_certificates).each do |cn| 127 | puts "=> #{cn}" 128 | cert = storage.get_certificate(cn) 129 | not_after = cert.certificate.not_after.utc 130 | 131 | lifetime = cert.certificate.not_after.utc - cert.certificate.not_before.utc 132 | remaining = cert.certificate.not_after.utc - Time.now.utc 133 | ratio = Rational(remaining,lifetime) 134 | 135 | has_to_renew = false 136 | has_to_renew ||= days && remaining < (days.to_i * 86400) 137 | has_to_renew ||= remaining_life && ratio < remaining_life 138 | 139 | puts " Not valid after: #{not_after} (lifetime=#{format_duration(lifetime+1)}, remaining=#{format_duration(remaining)}, #{"%0.2f" % (ratio.to_f*100)}%)" 140 | next unless has_to_renew 141 | 142 | puts " * Renewing: CN=#{cert.common_name}, SANs=#{cert.sans.join(',')}" 143 | order_with_private_key(cert.common_name, *cert.sans, private_key: regenerate_private_key(cert.public_key)) 144 | end 145 | end 146 | 147 | def add_san(common_name, *add_sans) 148 | puts "=> reissuing CN=#{common_name} with new SANs #{add_sans.join(?,)}" 149 | cert = storage.get_certificate(common_name) 150 | sans = cert.sans + add_sans 151 | puts " * SANs will be: #{sans.join(?,)}" 152 | order_with_private_key(cert.common_name, *sans, private_key: regenerate_private_key(cert.public_key)) 153 | end 154 | 155 | private 156 | 157 | # @param [Numeric] duration 158 | def format_duration(duration) 159 | raise ArgumentError if !duration.is_a?(Numeric) || duration < 0 160 | 161 | # Calculate components using divmod 162 | days, remainder = duration.divmod(86400) 163 | hours, remainder = remainder.divmod(3600) 164 | minutes, seconds = remainder.divmod(60) 165 | 166 | # Create [value, unit] pairs, filter out zero values, format, and join 167 | [[days, 'd'], [hours, 'h'], [minutes, 'm'], [seconds, 's']] 168 | .select { |v,| v > 0 } 169 | .map { |v, unit| "#{v.to_i}#{unit}" } 170 | .join 171 | end 172 | 173 | 174 | def config 175 | @config 176 | end 177 | 178 | def storage 179 | config.storage 180 | end 181 | 182 | def account_key 183 | @account_key ||= storage.get_account_key.tap do |x| 184 | x.key_passphrase = account_key_passphrase if account_key_passphrase 185 | end 186 | end 187 | 188 | def acme 189 | @acme ||= Acme::Client.new(private_key: account_key.private_key, directory: config.directory, connection_options: config.connection_options, bad_nonce_retry: config.bad_nonce_retry) 190 | end 191 | 192 | def certificate_key_passphrase 193 | if config['passphrase_from_env'] 194 | ENV['ACMESMITH_CERTIFICATE_KEY_PASSPHRASE'] || config['certificate_key_passphrase'] 195 | else 196 | config['certificate_key_passphrase'] 197 | end 198 | end 199 | 200 | def account_key_passphrase 201 | if config['passphrase_from_env'] 202 | ENV['ACMESMITH_ACCOUNT_KEY_PASSPHRASE'] || config['account_key_passphrase'] 203 | else 204 | config['account_key_passphrase'] 205 | end 206 | end 207 | 208 | def order_with_private_key(*identifiers, private_key:, not_before: nil, not_after: nil) 209 | order = OrderingService.new( 210 | acme: acme, 211 | identifiers: identifiers, 212 | private_key: private_key, 213 | challenge_responder_rules: config.challenge_responders, 214 | chain_preferences: config.chain_preferences, 215 | not_before: not_before, 216 | not_after: not_after 217 | ) 218 | order.perform! 219 | cert = order.certificate 220 | 221 | puts 222 | print " * securing into the storage ..." 223 | storage.put_certificate(cert, certificate_key_passphrase) 224 | puts " [ ok ]" 225 | puts 226 | 227 | execute_post_issue_hooks(cert) 228 | 229 | cert 230 | end 231 | 232 | def generate_private_key(key_type:, rsa_key_size:, elliptic_curve:) 233 | case key_type 234 | when 'rsa' 235 | OpenSSL::PKey::RSA.generate(rsa_key_size) 236 | when 'ec' 237 | OpenSSL::PKey::EC.generate(elliptic_curve) 238 | else 239 | raise ArgumentError, "Key type #{key_type} is not supported" 240 | end 241 | end 242 | 243 | # Generate a new key pair with the same type and key size / curve as existing one 244 | def regenerate_private_key(template) 245 | case template 246 | when OpenSSL::PKey::RSA 247 | OpenSSL::PKey::RSA.generate(template.n.num_bits) 248 | when OpenSSL::PKey::EC 249 | OpenSSL::PKey::EC.generate(template.group) 250 | else 251 | raise ArgumentError, "Unknown key type: #{template.class}" 252 | end 253 | end 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /lib/acmesmith/command.rb: -------------------------------------------------------------------------------- 1 | require 'thor' 2 | 3 | require 'acmesmith/config' 4 | require 'acmesmith/client' 5 | 6 | module Acmesmith 7 | class Command < Thor 8 | class_option :config, default: './acmesmith.yml', aliases: %w(-c) 9 | class_option :passphrase_from_env, type: :boolean, aliases: %w(-E), default: nil, desc: 'Read $ACMESMITH_ACCOUNT_KEY_PASSPHRASE and $ACMESMITH_CERTIFICATE_KEY_PASSPHRASE for passphrases' 10 | 11 | desc "new-account CONTACT", "Create account key (contact e.g. mailto:xxx@example.org)" 12 | def new_account(contact) 13 | puts "=> Creating an account ..." 14 | key = client.new_account(contact) 15 | puts "=> Public Key:" 16 | puts "\n#{key.private_key.public_key.to_pem}" 17 | end 18 | 19 | desc "authorize DOMAIN [DOMAIN ...]", "(Implementation disabled for v2) Get authz for DOMAIN." 20 | def authorize(*domains) 21 | warn "! WARNING: 'acmesmith authorize' is not available" 22 | warn "!" 23 | warn "! TL;DR: Go ahead; Just run 'acmesmith order'." 24 | warn "!" 25 | warn "! Pre-authorization have not implemented yet in acme-client.gem (v2) library." 26 | warn "! But, required domain authorizations will be performed automatically when ordering a certificate." 27 | warn "!" 28 | warn "! Pro Tips: Let's encrypt doesn't provide pre-authorization as of May 18, 2018." 29 | warn "!" 30 | # client.authorize(*domains) 31 | end 32 | 33 | desc "order COMMON_NAME [SAN]", "order certificate for CN +COMMON_NAME+ with SANs +SAN+" 34 | method_option :show_certificate, type: :boolean, aliases: %w(-s), default: true, desc: 'show an issued certificate in PEM and text when exiting' 35 | method_option :key_type, type: :string, enum: %w(rsa ec), default: 'rsa', desc: 'key type' 36 | method_option :rsa_key_size, type: :numeric, default: 2048, desc: 'size of RSA key' 37 | method_option :elliptic_curve, type: :string, default: 'prime256v1', desc: 'elliptic curve group for EC key' 38 | def order(common_name, *sans) 39 | cert = client.order( 40 | common_name, *sans, 41 | key_type: options[:key_type], 42 | rsa_key_size: options[:rsa_key_size], 43 | elliptic_curve: options[:elliptic_curve], 44 | ) 45 | if options[:show_certificate] 46 | puts cert.certificate.to_text 47 | puts cert.certificate.to_pem 48 | end 49 | end 50 | 51 | desc "post-issue-hooks COMMON_NAME", "Run all post-issuing hooks for common name. (for testing purpose)" 52 | def post_issue_hooks(common_name) 53 | client.post_issue_hooks(common_name) 54 | end 55 | map 'post-issue-hooks' => :post_issue_hooks 56 | 57 | desc "list [COMMON_NAME]", "list certificates or its versions" 58 | def list(common_name = nil) 59 | if common_name 60 | puts client.certificate_versions(common_name) 61 | else 62 | puts client.certificates_list 63 | end 64 | end 65 | 66 | desc "current COMMON_NAME", "show current version for certificate" 67 | def current(common_name) 68 | puts client.current(common_name) 69 | end 70 | 71 | desc "show-certificate COMMON_NAME", "show certificate" 72 | method_option :version, type: :string, default: 'current' 73 | method_option :type, type: :string, enum: %w(text certificate chain fullchain), default: 'text' 74 | def show_certificate(common_name) 75 | certs = client.get_certificate(common_name, version: options[:version], type: options[:type]) 76 | puts certs 77 | end 78 | map 'show-certiticate' => :show_certificate 79 | 80 | desc 'save-certificate COMMON_NAME', 'Save certificate to a file' 81 | method_option :version, type: :string, default: 'current' 82 | method_option :type, type: :string, enum: %w(certificate chain fullchain), default: 'fullchain' 83 | method_option :output, type: :string, required: true, banner: 'PATH', desc: 'Path to output file' 84 | method_option :mode, type: :string, default: '0600', desc: 'Mode (permission) of the output file on create' 85 | def save_certificate(common_name) 86 | client.save_certificate(common_name, version: options[:version], mode: options[:mode], output: options[:output], type: options[:type]) 87 | end 88 | 89 | desc "show-private-key COMMON_NAME", "show private key" 90 | method_option :version, type: :string, default: 'current' 91 | def show_private_key(common_name) 92 | puts client.get_private_key(common_name, version: options[:version]) 93 | end 94 | map 'show-private-key' => :show_private_key 95 | 96 | desc 'save-private-key COMMON_NAME', 'Save private key to a file' 97 | method_option :version, type: :string, default: 'current' 98 | method_option :output, type: :string, required: true, banner: 'PATH', desc: 'Path to output file' 99 | method_option :mode, type: :string, default: '0600', desc: 'Mode (permission) of the output file on create' 100 | def save_private_key(common_name) 101 | client.save_private_key(common_name, version: options[:version], mode: options[:mode], output: options[:output]) 102 | end 103 | 104 | desc 'save COMMON_NAME', 'Save (or update) certificate and key files.' 105 | method_option :version, type: :string, default: 'current' 106 | method_option :key_mode, type: :string, default: '0600', desc: 'Mode (permission) of the key file on create' 107 | method_option :certificate_mode, type: :string, default: '0644', desc: 'Mode (permission) of the certificate files on create' 108 | method_option :version_file, type: :string, required: false, banner: 'PATH', desc: 'Path to save a certificate version for following run (optional)' 109 | method_option :key_file, type: :string, required: false, banner: 'PATH', desc: 'Path to save a key' 110 | method_option :fullchain_file, type: :string, required: false , banner: 'PATH', desc: 'Path to save a certficiate and its chain (concatenated)' 111 | method_option :chain_file, type: :string, required: false , banner: 'PATH', desc: 'Path to save a certificate chain (root and intermediate CA)' 112 | method_option :certificate_file, type: :string, required: false, banner: 'PATH', desc: 'Path to save a certficiate' 113 | method_option :atomic, type: :boolean, default: true, desc: 'Enable atomic file update with rename(2)' 114 | def save(common_name) 115 | client.save( 116 | common_name, 117 | version: options[:version], 118 | key_mode: options[:key_mode], 119 | certificate_mode: options[:certificate_mode], 120 | version_file: options[:version_file], 121 | key_file: options[:key_file], 122 | fullchain_file: options[:fullchain_file], 123 | chain_file: options[:chain_file], 124 | certificate_file: options[:certificate_file], 125 | atomic: options[:atomic], 126 | verbose: true, 127 | ) 128 | end 129 | 130 | desc 'save-pkcs12 COMMON_NAME', 'Save ceriticate and private key to .p12 file' 131 | method_option :version, type: :string, default: 'current' 132 | method_option :output, type: :string, required: true, banner: 'PATH', desc: 'Path to output file' 133 | method_option :mode, type: :string, default: '0600', desc: 'Mode (permission) of the output file on create' 134 | def save_pkcs12(common_name) 135 | print 'Passphrase: ' 136 | passphrase = $stdin.noecho { $stdin.gets }.chomp 137 | print "\nPassphrase (confirm): " 138 | passphrase2 = $stdin.noecho { $stdin.gets }.chomp 139 | puts 140 | 141 | raise ArgumentError, "Passphrase doesn't match" if passphrase != passphrase2 142 | client.save_pkcs12(common_name, version: options[:version], mode: options[:mode], output: options[:output], passphrase: passphrase) 143 | end 144 | 145 | desc "autorenew [COMMON_NAMES]", "request renewal of certificates which expires soon" 146 | method_option :days, type: :numeric, aliases: %w(-d), default: nil, desc: 'specify threshold in days to select certificates to renew' 147 | method_option :remaining_life, type: :string, aliases: %w(-r), default: '1/3', desc: "Specify threshold based on remaining life. Accepts a percentage ('20%') or fraction ('1/3')" 148 | def autorenew(*common_names) 149 | remaining_life = case options[:remaining_life] 150 | when %r{\A\d+/\d+\z} 151 | Rational(options[:remaining_life]) 152 | when %r{\A([\d.]+)%\z} 153 | Rational($1.to_f, 100) 154 | when nil 155 | nil 156 | else 157 | raise ArgumentError, "invalid format for --remaining-life: it must be in '..%' or '../..'" 158 | end 159 | client.autorenew(days: options[:days], remaining_life: remaining_life, common_names: common_names.empty? ? nil : common_names) 160 | end 161 | 162 | desc "add-san COMMON_NAME [ADDITIONAL_SANS]", "request renewal of existing certificate with additional SANs" 163 | def add_san(common_name, *add_sans) 164 | client.add_san(common_name, *add_sans) 165 | end 166 | 167 | desc "register CONTACT", "(deprecated, use 'acmesmith new-account')" 168 | def register(contact) 169 | warn "!" 170 | warn "! DEPRECATION WARNING: Use 'acmesmith new-account' command" 171 | warn "! There is no user-facing breaking changes. It takes the same arguments with 'acmesmith register'." 172 | warn "!" 173 | warn "! This is due to change in semantics of ACME v2. ACME v2 defines 'new-account' instead of 'register' in v1." 174 | warn "!" 175 | new_account(contact) 176 | end 177 | 178 | desc "request COMMON_NAME [SAN]", "(deprecated, use 'acmesmith order')" 179 | method_option :show_certificate, type: :boolean, aliases: %w(-s), default: true, desc: 'show an issued certificate in PEM and text when exiting' 180 | def request(common_name, *sans) 181 | warn "!" 182 | warn "! DEPRECATION WARNING: Use 'acmesmith order' command" 183 | warn "! There is no user-facing breaking changes. It takes the same arguments with 'acmesmith request'." 184 | warn "!" 185 | warn "! This is due to change in semantics of ACME v2. ACME v2 defines 'order' instead of 'request' in v1." 186 | warn "!" 187 | order(common_name, *sans) 188 | end 189 | 190 | private 191 | 192 | def client 193 | config = Config.load_yaml(options[:config]) 194 | config.merge!("passphrase_from_env" => options[:passphrase_from_env]) unless options[:passphrase_from_env].nil? 195 | @client = Client.new(config: config) 196 | end 197 | end 198 | end 199 | -------------------------------------------------------------------------------- /lib/acmesmith/config.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | require 'acmesmith/storages' 3 | require 'acmesmith/challenge_responders' 4 | require 'acmesmith/challenge_responder_filter' 5 | require 'acmesmith/domain_name_filter' 6 | require 'acmesmith/post_issuing_hooks' 7 | 8 | module Acmesmith 9 | class Config 10 | ChallengeResponderRule = Struct.new(:challenge_responder, :filter, keyword_init: true) 11 | ChainPreference = Struct.new(:root_issuer_name, :root_issuer_key_id, :filter, keyword_init: true) 12 | 13 | def self.load_yaml(path) 14 | new YAML.load_file(path) 15 | end 16 | 17 | def initialize(config) 18 | @config = config 19 | validate 20 | end 21 | 22 | def validate 23 | unless @config['storage'] 24 | raise ArgumentError, "config['storage'] must be provided" 25 | end 26 | 27 | if @config['endpoint'] and !@config['directory'] 28 | raise ArgumentError, "config['directory'] must be provided, e.g. https://acme-v02.api.letsencrypt.org/directory or https://acme-staging-v02.api.letsencrypt.org/directory\n\nNOTE: We have dropped ACME v1 support since acmesmith v2.0.0. Specify v2 directory API URL using config['directory']." 29 | end 30 | 31 | unless @config['directory'] 32 | raise ArgumentError, "config['directory'] must be provided, e.g. https://acme-v02.api.letsencrypt.org/directory or https://acme-staging-v02.api.letsencrypt.org/directory" 33 | end 34 | 35 | if @config.key?('chain_preferences') && !@config.fetch('chain_preferences').kind_of?(Array) 36 | raise ArgumentError, "config['chain_preferences'] must be an Array" 37 | end 38 | end 39 | 40 | def [](key) 41 | @config[key] 42 | end 43 | 44 | def fetch(*args) 45 | @config.fetch(*args) 46 | end 47 | 48 | def merge!(pair) 49 | @config.merge!(pair) 50 | end 51 | 52 | def directory 53 | @config.fetch('directory') 54 | end 55 | 56 | def connection_options 57 | @config['connection_options'] || {} 58 | end 59 | 60 | def bad_nonce_retry 61 | @config['bad_nonce_retry'] || 0 62 | end 63 | 64 | def account_key_passphrase 65 | @config['account_key_passphrase'] 66 | end 67 | 68 | def certificate_key_passphrase 69 | @config['certificate_key_passphrase'] 70 | end 71 | 72 | def auto_authorize_on_request 73 | @config.fetch('auto_authorize_on_request', true) 74 | end 75 | 76 | def storage 77 | @storage ||= begin 78 | c = @config['storage'].dup 79 | Storages.find(c.delete('type')).new(**c.map{ |k,v| [k.to_sym, v]}.to_h) 80 | end 81 | end 82 | 83 | def post_issuing_hooks(common_name) 84 | if @config.key?('post_issuing_hooks') && @config['post_issuing_hooks'].key?(common_name) 85 | specs = @config['post_issuing_hooks'][common_name] 86 | specs.flat_map do |specs_sub| 87 | specs_sub.map do |k, v| 88 | PostIssuingHooks.find(k).new(**v.map{ |k_,v_| [k_.to_sym, v_]}.to_h) 89 | end 90 | end 91 | else 92 | [] 93 | end 94 | end 95 | 96 | def challenge_responders 97 | @challenge_responders ||= begin 98 | specs = @config['challenge_responders'].kind_of?(Hash) ? @config['challenge_responders'].map { |k,v| [k => v] } : @config['challenge_responders'] 99 | specs.flat_map do |specs_sub| 100 | specs_sub = specs_sub.dup 101 | filter = (specs_sub.delete('filter') || {}).map { |k,v| [k.to_sym, v] }.to_h 102 | specs_sub.map do |k,v| 103 | responder = ChallengeResponders.find(k).new(**v.map{ |k_,v_| [k_.to_sym, v_]}.to_h) 104 | ChallengeResponderRule.new( 105 | challenge_responder: responder, 106 | filter: ChallengeResponderFilter.new(responder, **filter), 107 | ) 108 | end 109 | end 110 | end 111 | end 112 | 113 | def chain_preferences 114 | @preferred_chains ||= begin 115 | specs = @config['chain_preferences'] || [] 116 | specs.flat_map do |spec| 117 | filter = spec.fetch('filter', {}).map { |k,v| [k.to_sym, v] }.to_h 118 | ChainPreference.new( 119 | root_issuer_name: spec['root_issuer_name'], 120 | root_issuer_key_id: spec['root_issuer_key_id'], 121 | filter: DomainNameFilter.new(**filter), 122 | ) 123 | end 124 | end 125 | end 126 | 127 | # def post_actions 128 | # end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/acmesmith/domain_name_filter.rb: -------------------------------------------------------------------------------- 1 | module Acmesmith 2 | class DomainNameFilter 3 | def initialize(exact: nil, suffix: nil, regexp: nil) 4 | @exact = exact && [*exact].flatten.compact 5 | @suffix = suffix && [*suffix].flatten.compact 6 | @regexp = regexp && [*regexp].flatten.compact.map{ |_| Regexp.new(_) } 7 | end 8 | 9 | def match?(domain) 10 | if @exact 11 | return false unless @exact.include?(domain) 12 | end 13 | if @suffix 14 | return false unless @suffix.any? { |suffix| domain.end_with?(suffix) } 15 | end 16 | if @regexp 17 | return false unless @regexp.any? { |regexp| domain.match?(regexp) } 18 | end 19 | true 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/acmesmith/ordering_service.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/authorization_service' 2 | require 'acmesmith/certificate' 3 | require 'acmesmith/certificate_retrieving_service' 4 | 5 | module Acmesmith 6 | class OrderingService 7 | class NotCompleted < StandardError; end 8 | 9 | # @param acme [Acme::Client] ACME client 10 | # @param identifiers [Array] Array of domain names for a ordering certificate. The first item will be a common name. 11 | # @param private_key [OpenSSL::PKey::PKey] Private key 12 | # @param challenge_responder_rules [Array] responders 13 | # @param chain_preferences [Array] chain_preferences 14 | # @param not_before [Time] 15 | # @param not_after [Time] 16 | def initialize(acme:, identifiers:, private_key:, challenge_responder_rules:, chain_preferences:, not_before: nil, not_after: nil) 17 | @acme = acme 18 | @identifiers = identifiers 19 | @private_key = private_key 20 | @challenge_responder_rules = challenge_responder_rules 21 | @chain_preferences = chain_preferences 22 | @not_before = not_before 23 | @not_after = not_after 24 | end 25 | 26 | attr_reader :acme, :identifiers, :private_key, :challenge_responder_rules, :chain_preferences, :not_before, :not_after 27 | 28 | def perform! 29 | puts "=> Ordering a certificate for the following identifiers:" 30 | puts 31 | puts " * CN: #{common_name}" 32 | sans.each do |san| 33 | puts " * SAN: #{san}" 34 | end 35 | 36 | puts 37 | puts "=> Placing an order" 38 | @order = acme.new_order(identifiers: identifiers, not_before: not_before, not_after: not_after) 39 | puts " * URL: #{order.url}" 40 | 41 | ensure_authorization() 42 | 43 | finalize_order() 44 | wait_order_for_complete() 45 | 46 | @certificate = Certificate.by_issuance(pem_chain, csr) 47 | 48 | puts 49 | puts "=> Certificate issued" 50 | nil 51 | end 52 | 53 | def ensure_authorization 54 | return if order.authorizations.empty? || order.status == 'ready' 55 | puts "=> Looking for required domain authorizations" 56 | puts 57 | order.authorizations.map(&:domain).each do |domain| 58 | puts " * #{domain}" 59 | end 60 | puts 61 | 62 | AuthorizationService.new(challenge_responder_rules, order.authorizations).perform! 63 | end 64 | 65 | def finalize_order 66 | puts 67 | puts "=> Finalizing the order" 68 | puts 69 | puts csr.csr.to_pem 70 | puts 71 | 72 | print " * Requesting..." 73 | order.finalize(csr: csr) 74 | puts" [ ok ]" 75 | end 76 | 77 | def wait_order_for_complete 78 | while %w(ready processing).include?(order.status) 79 | order.reload() 80 | puts " * Waiting for complete: status=#{order.status}" 81 | sleep 2 82 | end 83 | end 84 | 85 | # @return String 86 | def pem_chain 87 | url = order.certificate_url or raise NotCompleted, "not completed yet" 88 | CertificateRetrievingService.new(acme, common_name, url, chain_preferences: chain_preferences).pem_chain 89 | end 90 | 91 | def certificate 92 | @certificate or raise NotCompleted, "not completed yet" 93 | end 94 | 95 | # @return Acme::Client::Resources::Order[] 96 | def order 97 | @order or raise "BUG: order not yet generated" 98 | end 99 | 100 | # @return [String] 101 | def common_name 102 | identifiers.first 103 | end 104 | 105 | # @return [Array] 106 | def sans 107 | identifiers[1..-1] 108 | end 109 | 110 | # @return [Acme::Client::CertificateRequest] 111 | def csr 112 | @csr ||= Acme::Client::CertificateRequest.new(subject: { common_name: common_name }, names: sans, private_key: private_key) 113 | end 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/acmesmith/post_issueing_hooks.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/post_issuing_hooks' 2 | 3 | warn "!! DEPRECATION WARNING: PostIssueingHooks is deprecated, use PostIssuingHooks (#{caller[0]})" 4 | 5 | 6 | -------------------------------------------------------------------------------- /lib/acmesmith/post_issueing_hooks/base.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/post_issuing_hooks/base' 2 | 3 | warn "!! DEPRECATION WARNING: PostIssueingHooks::Base is deprecated, use PostIssuingHooks::Base (#{caller[0]})" 4 | 5 | module Acmesmith 6 | module PostIssueingHooks 7 | Base = PostIssuingHooks::Base 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/acmesmith/post_issuing_hooks.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/utils/finder' 2 | 3 | module Acmesmith 4 | module PostIssuingHooks 5 | def self.find(name) 6 | Utils::Finder.find(self, 'acmesmith/post_issuing_hooks', name) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/acmesmith/post_issuing_hooks/acm.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk-acm' 2 | require 'acmesmith/post_issuing_hooks/base' 3 | 4 | module Acmesmith 5 | module PostIssuingHooks 6 | class Acm < Base 7 | def initialize(certificate_arn: nil, region:) 8 | @certificate_arn = certificate_arn 9 | @certificate_arn_set = true if @certificate_arn 10 | @region = region 11 | end 12 | 13 | attr_reader :region 14 | 15 | def certificate_arn 16 | return @certificate_arn if @certificate_arn_set 17 | @certificate_arn ||= find_certificate_arn 18 | @certificate_arn_set = true 19 | @certificate_arn 20 | end 21 | 22 | def find_certificate_arn 23 | acm.list_certificates().each do |page| 24 | page.certificate_summary_list.each do |summary| 25 | if summary.domain_name == common_name 26 | tags = acm.list_tags_for_certificate(certificate_arn: summary.certificate_arn).tags 27 | if tags.find{ |_| _.key == 'Acmesmith' } 28 | return summary.certificate_arn 29 | end 30 | end 31 | end 32 | end 33 | end 34 | 35 | def acm 36 | @acm ||= Aws::ACM::Client.new(region: region) 37 | end 38 | 39 | def execute 40 | puts "=> Importing certificate CN=#{common_name} into AWS ACM (region=#{region})" 41 | if certificate_arn 42 | puts " * updating ARN: #{certificate_arn}" 43 | else 44 | puts " * Importing as as new certificate" 45 | end 46 | 47 | resp = acm.import_certificate( 48 | { 49 | certificate: certificate.certificate.to_pem, 50 | private_key: certificate.private_key.to_pem, 51 | certificate_chain: certificate.issuer_pems, 52 | }.merge(certificate_arn ? {certificate_arn: certificate_arn} : {}) 53 | ) 54 | unless certificate_arn 55 | puts " * ARN: #{resp.certificate_arn}" 56 | end 57 | 58 | acm.add_tags_to_certificate( 59 | certificate_arn: resp.certificate_arn, 60 | tags: [key: 'Acmesmith', value: '1'], 61 | ) 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/acmesmith/post_issuing_hooks/base.rb: -------------------------------------------------------------------------------- 1 | module Acmesmith 2 | module PostIssuingHooks 3 | class Base 4 | attr_reader :certificate 5 | 6 | def common_name 7 | certificate.common_name 8 | end 9 | 10 | def run(certificate:) 11 | @certificate = certificate 12 | execute 13 | end 14 | 15 | def execute 16 | raise NotImplementedError 17 | end 18 | end 19 | end 20 | end 21 | 22 | -------------------------------------------------------------------------------- /lib/acmesmith/post_issuing_hooks/shell.rb: -------------------------------------------------------------------------------- 1 | require 'open3' 2 | require 'acmesmith/post_issuing_hooks/base' 3 | 4 | module Acmesmith 5 | module PostIssuingHooks 6 | class Shell < Base 7 | def initialize(command:, ignore_failure: false) 8 | @command = command 9 | @ignore_failure = ignore_failure 10 | end 11 | 12 | def execute 13 | puts "=> Executing Post Issueing Hook for #{common_name} in #{self.class.name}" 14 | puts " $ #{@command}" 15 | 16 | status = system({"COMMON_NAME" => common_name}, @command) 17 | 18 | unless status 19 | if @ignore_failure 20 | $stderr.puts " ! execution failed" 21 | else 22 | raise "Execution failed" 23 | end 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/acmesmith/save_certificate_service.rb: -------------------------------------------------------------------------------- 1 | module Acmesmith 2 | class SaveCertificateService 3 | def initialize(cert, key_mode: '0600', certificate_mode: '0644', version_file: nil, key_file: nil, fullchain_file: nil, chain_file: nil, certificate_file: nil, atomic: true, verbose: false) 4 | @cert = cert 5 | @key_mode = key_mode 6 | @certificate_mode = certificate_mode 7 | @version_file = version_file 8 | @key_file = key_file 9 | @fullchain_file = fullchain_file 10 | @chain_file = chain_file 11 | @certificate_file = certificate_file 12 | @atomic = atomic 13 | @verbose = verbose 14 | end 15 | 16 | attr_reader :cert 17 | attr_reader :key_mode, :certificate_mode 18 | attr_reader :version_file, :key_file, :fullchain_file, :chain_file, :certificate_file 19 | def atomic?; !!@atomic; end 20 | 21 | def perform! 22 | if local_version == cert.version 23 | return 24 | end 25 | 26 | log "Saving certificate CN=#{cert.common_name} (ver: #{cert.version})" 27 | 28 | write_file(key_file, key_mode, cert.private_key) 29 | write_file(certificate_file, certificate_mode, cert.certificate.to_pem) 30 | write_file(chain_file, certificate_mode, cert.chain) 31 | write_file(fullchain_file, certificate_mode, cert.fullchain) 32 | write_file(version_file, certificate_mode, cert.version) 33 | end 34 | 35 | def local_version 36 | @local_version ||= begin 37 | if version_file && File.exist?(version_file) 38 | File.read(version_file).chomp 39 | else 40 | nil 41 | end 42 | end 43 | end 44 | 45 | private 46 | 47 | def log(*args) 48 | if @verbose 49 | puts *args 50 | end 51 | end 52 | 53 | def write_file(path, mode, body) 54 | return unless path 55 | realpath = atomic? ? "#{path}.new" : path 56 | File.open(realpath, 'w', mode.to_i(8)) do |io| 57 | io.puts body 58 | end 59 | if atomic? 60 | File.rename realpath, path 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/acmesmith/storages.rb: -------------------------------------------------------------------------------- 1 | require 'acmesmith/utils/finder' 2 | 3 | module Acmesmith 4 | module Storages 5 | def self.find(name) 6 | Utils::Finder.find(self, 'acmesmith/storages', name) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/acmesmith/storages/base.rb: -------------------------------------------------------------------------------- 1 | module Acmesmith 2 | module Storages 3 | class Base 4 | class NotExist < StandardError; end 5 | class AlreadyExist < StandardError; end 6 | 7 | def initialize() 8 | end 9 | 10 | # @return [Acmesmith::AccountKey] 11 | def get_account_key 12 | raise NotImplementedError 13 | end 14 | 15 | # @param key [Acmesmith::AccountKey] 16 | # @param passphrase [String, nil] 17 | def put_account_key(key, passphrase = nil) 18 | raise NotImplementedError 19 | end 20 | 21 | # @param cert [Acmesmith::Certificate] 22 | # @param passphrase [String, nil] 23 | # @param update_current [true, false] 24 | def put_certificate(cert, passphrase = nil, update_current: true) 25 | raise NotImplementedError 26 | end 27 | 28 | # @param common_name [String] 29 | # @param version [String, nil] 30 | # @return [Acmesmith::Certificate] 31 | def get_certificate(common_name, version: 'current') 32 | raise NotImplementedError 33 | end 34 | 35 | # @param common_name [String] 36 | # @return [String] array of common_names 37 | def list_certificates 38 | raise NotImplementedError 39 | end 40 | 41 | # @param common_name [String] 42 | # @return [String] array of versions 43 | def list_certificate_versions(common_name) 44 | raise NotImplementedError 45 | end 46 | 47 | # @param common_name [String] 48 | # @return [String] current version 49 | def get_current_certificate_version(common_name) 50 | raise NotImplementedError 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/acmesmith/storages/filesystem.rb: -------------------------------------------------------------------------------- 1 | require 'pathname' 2 | 3 | require 'acmesmith/storages/base' 4 | require 'acmesmith/account_key' 5 | require 'acmesmith/certificate' 6 | 7 | module Acmesmith 8 | module Storages 9 | class Filesystem < Base 10 | def initialize(path:) 11 | @path = Pathname(path) 12 | end 13 | 14 | attr_reader :path 15 | 16 | def get_account_key 17 | raise NotExist.new("Account key doesn't exist") unless account_key_path.exist? 18 | AccountKey.new account_key_path.read 19 | end 20 | 21 | def put_account_key(key, passphrase = nil) 22 | raise AlreadyExist if account_key_path.exist? 23 | File.write account_key_path.to_s, key.export(passphrase), 0, perm: 0600 24 | end 25 | 26 | def put_certificate(cert, passphrase = nil, update_current: true) 27 | h = cert.export(passphrase) 28 | certificate_base_path(cert.common_name, cert.version).mkpath 29 | File.write certificate_path(cert.common_name, cert.version), "#{h[:certificate].rstrip}\n" 30 | File.write chain_path(cert.common_name, cert.version), "#{h[:chain].rstrip}\n" 31 | File.write fullchain_path(cert.common_name, cert.version), "#{h[:fullchain].rstrip}\n" 32 | File.write private_key_path(cert.common_name, cert.version), "#{h[:private_key].rstrip}\n", 0, perm: 0600 33 | if update_current 34 | File.symlink(cert.version, certificate_base_path(cert.common_name, 'current.new')) 35 | File.rename(certificate_base_path(cert.common_name, 'current.new'), certificate_base_path(cert.common_name, 'current')) 36 | end 37 | end 38 | 39 | def get_certificate(common_name, version: 'current') 40 | raise NotExist.new("Certificate for #{common_name.inspect} of #{version} version doesn't exist") unless certificate_base_path(common_name, version).exist? 41 | certificate = certificate_path(common_name, version).read 42 | chain = chain_path(common_name, version).read 43 | private_key = private_key_path(common_name, version).read 44 | Certificate.new(certificate, chain, private_key) 45 | end 46 | 47 | def list_certificates 48 | Dir[path.join('certs', '*').to_s].map { |_| File.basename(_) } 49 | end 50 | 51 | def list_certificate_versions(common_name) 52 | Dir[path.join('certs', common_name, '*').to_s].map { |_| File.basename(_) }.reject { |_| _ == 'current' } 53 | end 54 | 55 | def get_current_certificate_version(common_name) 56 | path.join('certs', common_name, 'current').readlink 57 | end 58 | 59 | private 60 | 61 | def account_key_path 62 | path.join('account.pem') 63 | end 64 | 65 | def certificate_base_path(cn, ver) 66 | path.join('certs', cn, ver) 67 | end 68 | 69 | def certificate_path(cn, ver) 70 | certificate_base_path(cn, ver).join('cert.pem') 71 | end 72 | 73 | def private_key_path(cn, ver) 74 | certificate_base_path(cn, ver).join('key.pem') 75 | end 76 | 77 | def chain_path(cn, ver) 78 | certificate_base_path(cn, ver).join('chain.pem') 79 | end 80 | 81 | def fullchain_path(cn, ver) 82 | certificate_base_path(cn, ver).join('fullchain.pem') 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/acmesmith/storages/s3.rb: -------------------------------------------------------------------------------- 1 | require 'aws-sdk-s3' 2 | 3 | require 'acmesmith/storages/base' 4 | require 'acmesmith/account_key' 5 | require 'acmesmith/certificate' 6 | 7 | module Acmesmith 8 | module Storages 9 | class S3 < Base 10 | def initialize(aws_access_key: nil, bucket:, prefix: nil, region:, use_kms: true, kms_key_id: nil, kms_key_id_account: nil, kms_key_id_certificate_key: nil, pkcs12_passphrase: nil, pkcs12_common_names: nil, endpoint: nil) 11 | @region = region 12 | @bucket = bucket 13 | @prefix = prefix 14 | if @prefix && !@prefix.end_with?('/') 15 | @prefix += '/' 16 | end 17 | 18 | @pkcs12_passphrase = pkcs12_passphrase 19 | @pkcs12_common_names = pkcs12_common_names 20 | 21 | @use_kms = use_kms 22 | @kms_key_id = kms_key_id 23 | @kms_key_id_account = kms_key_id_account 24 | @kms_key_id_certificate_key = kms_key_id_certificate_key 25 | 26 | @s3 = Aws::S3::Client.new({region: region}.tap do |opt| 27 | opt[:credentials] = Aws::Credentials.new(aws_access_key['access_key_id'], aws_access_key['secret_access_key'], aws_access_key['session_token']) if aws_access_key 28 | opt[:endpoint] = endpoint if endpoint 29 | end) 30 | end 31 | 32 | attr_reader :region, :bucket, :prefix, :use_kms, :kms_key_id, :kms_key_id_account, :kms_key_id_certificate_key 33 | 34 | def get_account_key 35 | obj = @s3.get_object(bucket: bucket, key: account_key_key) 36 | AccountKey.new obj.body.read 37 | rescue Aws::S3::Errors::NoSuchKey 38 | raise NotExist.new("Account key doesn't exist") 39 | end 40 | 41 | def account_key_exist? 42 | begin 43 | get_account_key 44 | rescue NotExist 45 | return false 46 | else 47 | return true 48 | end 49 | end 50 | 51 | def put_account_key(key, passphrase = nil) 52 | raise AlreadyExist if account_key_exist? 53 | params = { 54 | bucket: bucket, 55 | key: account_key_key, 56 | body: key.export(passphrase), 57 | content_type: 'application/x-pem-file', 58 | } 59 | if use_kms 60 | params[:server_side_encryption] = 'aws:kms' 61 | key_id = kms_key_id_account || kms_key_id 62 | params[:ssekms_key_id] = key_id if key_id 63 | end 64 | 65 | @s3.put_object(params) 66 | end 67 | 68 | def put_certificate(cert, passphrase = nil, update_current: true) 69 | h = cert.export(passphrase) 70 | 71 | put = -> (key, body, kms, content_type = 'application/x-pem-file') do 72 | params = { 73 | bucket: bucket, 74 | key: key, 75 | body: body, 76 | content_type: content_type, 77 | } 78 | if kms 79 | params[:server_side_encryption] = 'aws:kms' 80 | key_id = kms_key_id_certificate_key || kms_key_id 81 | params[:ssekms_key_id] = key_id if key_id 82 | end 83 | @s3.put_object(params) 84 | end 85 | 86 | put.call certificate_key(cert.common_name, cert.version), "#{h[:certificate].rstrip}\n", false 87 | put.call chain_key(cert.common_name, cert.version), "#{h[:chain].rstrip}\n", false 88 | put.call fullchain_key(cert.common_name, cert.version), "#{h[:fullchain].rstrip}\n", false 89 | put.call private_key_key(cert.common_name, cert.version), "#{h[:private_key].rstrip}\n", use_kms 90 | 91 | if generate_pkcs12?(cert) 92 | put.call pkcs12_key(cert.common_name, cert.version), "#{cert.pkcs12(@pkcs12_passphrase).to_der}\n", use_kms, 'application/x-pkcs12' 93 | end 94 | 95 | if update_current 96 | @s3.put_object( 97 | bucket: bucket, 98 | key: certificate_current_key(cert.common_name), 99 | content_type: 'text/plain', 100 | body: cert.version, 101 | ) 102 | end 103 | end 104 | 105 | def get_certificate(common_name, version: 'current') 106 | version = certificate_current(common_name) if version == 'current' 107 | 108 | certificate = @s3.get_object(bucket: bucket, key: certificate_key(common_name, version)).body.read 109 | chain = @s3.get_object(bucket: bucket, key: chain_key(common_name, version)).body.read 110 | private_key = @s3.get_object(bucket: bucket, key: private_key_key(common_name, version)).body.read 111 | Certificate.new(certificate, chain, private_key) 112 | rescue Aws::S3::Errors::NoSuchKey 113 | raise NotExist.new("Certificate for #{common_name.inspect} of #{version} version doesn't exist") 114 | end 115 | 116 | def list_certificates 117 | certs_prefix = "#{prefix}certs/" 118 | @s3.list_objects( 119 | bucket: bucket, 120 | delimiter: '/', 121 | prefix: certs_prefix, 122 | ).each.flat_map do |page| 123 | regexp = /\A#{Regexp.escape(certs_prefix)}/ 124 | page.common_prefixes.map { |_| _.prefix.sub(regexp, '').sub(/\/.+\z/, '').sub(/\/\z/, '') }.uniq 125 | end 126 | end 127 | 128 | def list_certificate_versions(common_name) 129 | cert_ver_prefix = "#{prefix}certs/#{common_name}/" 130 | @s3.list_objects( 131 | bucket: bucket, 132 | delimiter: '/', 133 | prefix: cert_ver_prefix, 134 | ).each.flat_map do |page| 135 | regexp = /\A#{Regexp.escape(cert_ver_prefix)}/ 136 | page.common_prefixes.map { |_| _.prefix.sub(regexp, '').sub(/\/.+\z/, '').sub(/\/\z/, '') }.uniq 137 | end.reject { |_| _ == 'current' } 138 | end 139 | 140 | def get_current_certificate_version(common_name) 141 | certificate_current(common_name) 142 | end 143 | 144 | private 145 | 146 | def account_key_key 147 | "#{prefix}account.pem" 148 | end 149 | 150 | def certificate_base_key(cn, ver) 151 | "#{prefix}certs/#{cn}/#{ver}" 152 | end 153 | 154 | def certificate_current_key(cn) 155 | certificate_base_key(cn, 'current') 156 | end 157 | 158 | def certificate_current(cn) 159 | @s3.get_object( 160 | bucket: bucket, 161 | key: certificate_current_key(cn), 162 | ).body.read.chomp 163 | rescue Aws::S3::Errors::NoSuchKey 164 | raise NotExist.new("Certificate for #{cn.inspect} of current version doesn't exist") 165 | end 166 | 167 | def certificate_key(cn, ver) 168 | "#{certificate_base_key(cn, ver)}/cert.pem" 169 | end 170 | 171 | def private_key_key(cn, ver) 172 | "#{certificate_base_key(cn, ver)}/key.pem" 173 | end 174 | 175 | def chain_key(cn, ver) 176 | "#{certificate_base_key(cn, ver)}/chain.pem" 177 | end 178 | 179 | def fullchain_key(cn, ver) 180 | "#{certificate_base_key(cn, ver)}/fullchain.pem" 181 | end 182 | 183 | def pkcs12_key(cn, ver) 184 | "#{certificate_base_key(cn, ver)}/cert.p12" 185 | end 186 | 187 | def generate_pkcs12?(cert) 188 | if @pkcs12_passphrase 189 | @pkcs12_common_names.nil? || @pkcs12_common_names.include?(cert.common_name) 190 | end 191 | end 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /lib/acmesmith/utils/finder.rb: -------------------------------------------------------------------------------- 1 | module Acmesmith 2 | module Utils 3 | module Finder 4 | class NotFound < StandardError; end 5 | 6 | def self.find(const, prefix, name, error: true) 7 | retried = false 8 | constant_name = name.to_s.gsub(/\A.|_./) { |s| s[-1].upcase } 9 | 10 | begin 11 | const.const_get constant_name, false 12 | rescue NameError 13 | unless retried 14 | begin 15 | require "#{prefix}/#{name}" 16 | rescue LoadError 17 | end 18 | 19 | retried = true 20 | retry 21 | end 22 | 23 | if error 24 | raise NotFound, "Couldn't find #{name.inspect} for #{const}" 25 | else 26 | nil 27 | end 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/acmesmith/version.rb: -------------------------------------------------------------------------------- 1 | module Acmesmith 2 | VERSION = "2.7.0" 3 | end 4 | -------------------------------------------------------------------------------- /script/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "acmesmith" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start 15 | -------------------------------------------------------------------------------- /script/setup: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | 5 | bundle install 6 | 7 | # Do any other automated setup that you need to do here 8 | -------------------------------------------------------------------------------- /spec/account_key_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'openssl' 3 | 4 | require 'acmesmith/account_key' 5 | 6 | RSpec.describe Acmesmith::AccountKey do 7 | let(:given_private_key) { PRIVATE_KEY_PEM } 8 | let(:given_passphrase) { nil } 9 | 10 | subject(:account_key) do 11 | described_class.new( 12 | given_private_key, 13 | given_passphrase, 14 | ) 15 | end 16 | 17 | context "with String" do 18 | let(:given_private_key) { PRIVATE_KEY_PEM } 19 | 20 | it "works" do 21 | expect(account_key.private_key).to be_a(OpenSSL::PKey::RSA) 22 | expect(account_key.private_key.to_pem).to eq(PRIVATE_KEY.to_pem) 23 | end 24 | end 25 | 26 | context "with OpenSSL" do 27 | let(:given_private_key) { PRIVATE_KEY } 28 | 29 | it "works" do 30 | expect(account_key.private_key).to be_a(OpenSSL::PKey::RSA) 31 | expect(account_key.private_key.to_pem).to eq(PRIVATE_KEY.to_pem) 32 | end 33 | end 34 | 35 | describe "#private_key" do 36 | let(:given_private_key) { PRIVATE_KEY_PEM_ENCRYPTED } 37 | subject(:private_key) { account_key.private_key } 38 | 39 | context "when passphrase is not given" do 40 | let(:given_passphrase) { nil } 41 | 42 | it "raises error" do 43 | expect { subject }.to raise_error(Acmesmith::AccountKey::PassphraseRequired) 44 | end 45 | end 46 | 47 | context "when passphrase is given at initialize" do 48 | let(:given_passphrase) { PASSPHRASE } 49 | it "works" do 50 | expect(private_key).to be_a(OpenSSL::PKey::RSA) 51 | expect(private_key.to_pem).to eq(PRIVATE_KEY.to_pem) 52 | end 53 | end 54 | 55 | context "when passphrase is given later" do 56 | let(:given_passphrase) { nil } 57 | 58 | before do 59 | account_key.key_passphrase = PASSPHRASE 60 | end 61 | 62 | it "works" do 63 | expect(private_key).to be_a(OpenSSL::PKey::RSA) 64 | expect(private_key.to_pem).to eq(PRIVATE_KEY.to_pem) 65 | end 66 | end 67 | 68 | context "when passphrase is given twice" do 69 | let(:given_passphrase) { PASSPHRASE } 70 | it "raises error" do 71 | expect { account_key.key_passphrase = PASSPHRASE }.to raise_error(Acmesmith::AccountKey::PrivateKeyDecrypted) 72 | end 73 | end 74 | end 75 | 76 | describe "#export" do 77 | let(:export_passphrase) { nil } 78 | subject(:export) { account_key.export(export_passphrase) } 79 | 80 | it "works" do 81 | expect(export).to eq(PRIVATE_KEY.to_pem) 82 | end 83 | 84 | context "with passphrase" do 85 | let(:export_passphrase) { PASSPHRASE } 86 | it "works" do 87 | expect(export).to be_a(String) 88 | expect { OpenSSL::PKey::RSA.new(export, '') }.to raise_error(OpenSSL::PKey::RSAError) 89 | expect(OpenSSL::PKey::RSA.new(export, export_passphrase).to_pem).to eq(PRIVATE_KEY.to_pem) 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /spec/certificate_retrieving_service_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'openssl' 3 | 4 | require 'acmesmith/certificate_retrieving_service' 5 | 6 | RSpec.describe Acmesmith::CertificateRetrievingService::CertificateChain do 7 | # 1. end-entity 8 | # 2. s=(STAGING) Artificial Apricot R3, i=(STAGING) Pretend Pear X1 9 | # 3. s=(STAGING) Pretend Pear X1, i=(STAGING) Doctored Durian Root CA X3 10 | TEST_CHAIN_1 = <<~EOF 11 | -----BEGIN CERTIFICATE----- 12 | MIIFXDCCBESgAwIBAgITAPpyplhpKssE1b2toRhXUlfWbzANBgkqhkiG9w0BAQsF 13 | ADBZMQswCQYDVQQGEwJVUzEgMB4GA1UEChMXKFNUQUdJTkcpIExldCdzIEVuY3J5 14 | cHQxKDAmBgNVBAMTHyhTVEFHSU5HKSBBcnRpZmljaWFsIEFwcmljb3QgUjMwHhcN 15 | MjExMDMxMTkwMTA5WhcNMjIwMTI5MTkwMTA4WjAeMRwwGgYDVQQDExNkZXYyMDIx 16 | MTEwMWEuMHcwLmNvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3TM2 17 | 0x1kfkQB17D6HwgsZIgc1pWpRQpxKsutu+9ugWPpkHbQo55xiageBoyFEu8lh5g0 18 | WsQ8w8aPobZK/GytQvPinXLGwkByB9thuZBqaxijma1Y9sfozts0aqH4ehrdrMyi 19 | I6aZ5RsOn9dV3t6zXlDcl2ZebPcmZlTHZQJrzBHuIfBflDZbqGef7FdtasDfZGra 20 | uqXEUeG3++yiBihQPwJDH+8t3rF1Ijjj2YRGrYmkucLI7a21abkyeYd1LUZs/U9Z 21 | uTadT0YCr8TYmSssF67P5wSjSrsHm9KXjLwn6JJU5dC4qJFni/wD0FluQnVkQZgP 22 | V5i6q+S2Rn06i1/NgQIDAQABo4ICVjCCAlIwDgYDVR0PAQH/BAQDAgWgMB0GA1Ud 23 | JQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW 24 | BBRbuPijqD9LoqIMoyUb3QtMXkf6QDAfBgNVHSMEGDAWgBTecnpI3zHDplDfn4Uj 25 | 31c3S10uZTBdBggrBgEFBQcBAQRRME8wJQYIKwYBBQUHMAGGGWh0dHA6Ly9zdGct 26 | cjMuby5sZW5jci5vcmcwJgYIKwYBBQUHMAKGGmh0dHA6Ly9zdGctcjMuaS5sZW5j 27 | ci5vcmcvMB4GA1UdEQQXMBWCE2RldjIwMjExMTAxYS4wdzAuY28wTAYDVR0gBEUw 28 | QzAIBgZngQwBAgEwNwYLKwYBBAGC3xMBAQEwKDAmBggrBgEFBQcCARYaaHR0cDov 29 | L2Nwcy5sZXRzZW5jcnlwdC5vcmcwggEEBgorBgEEAdZ5AgQCBIH1BIHyAPAAdgCw 30 | zIPlpfl9a698CcwoSQSHKsfoixMsY1C3xv0m4WxsdwAAAXzX8QZIAAAEAwBHMEUC 31 | IQCyLDw5DX5cFUyz7o8QjD1/s5vpPzOLXQE6UcCBMXp4NAIgcLOw3O50DZ0nK2rD 32 | MSiGTK5cdQTRcwqQcltcxBeDgXsAdgDdmTT8peckgMlWaH2BNJkISbJJ97Vp2Me8 33 | qz9cwfNuZAAAAXzX8QgeAAAEAwBHMEUCIQDK5IiE6LksDEoQ+fm+obf+dXD8yvbE 34 | hDgDZQMgkwvTEgIgEGsyglHs6V7X6keAKMmnAqT862tfo1+TCTeBnH22Oh0wDQYJ 35 | KoZIhvcNAQELBQADggEBAByZRHXVDp1VeZAlLeZS/2bL53hxKkGaUdSXULU2VVag 36 | jrehvdEsLHJfmm5i70F0SyWpPW4kDVP1tUxQ8uqPGkwVS53cldDRZr5dAM0TTuGh 37 | O4dTPOj8ziGfwdbD7gkHmgpDUR97pepyfOVgTGY3VaPVDnBfWpVaGZR+79BJc6qu 38 | dpq74CwWdrDP3jKXeeahvIgqZumUq6pC5CGY/k4GRdfW+KCFk0PqZH75Prxj7L+I 39 | CqgmIJ4t9U/czcBkA3sFxNGkZR9d8B6xHWYesvetiEah5tDIWybn9kEwqEWzXm4S 40 | 3PBPxeNRAeQxux460FHiLk/CXllnGSlU9yMKa++hw14= 41 | -----END CERTIFICATE----- 42 | 43 | -----BEGIN CERTIFICATE----- 44 | MIIFWzCCA0OgAwIBAgIQTfQrldHumzpMLrM7jRBd1jANBgkqhkiG9w0BAQsFADBm 45 | MQswCQYDVQQGEwJVUzEzMDEGA1UEChMqKFNUQUdJTkcpIEludGVybmV0IFNlY3Vy 46 | aXR5IFJlc2VhcmNoIEdyb3VwMSIwIAYDVQQDExkoU1RBR0lORykgUHJldGVuZCBQ 47 | ZWFyIFgxMB4XDTIwMDkwNDAwMDAwMFoXDTI1MDkxNTE2MDAwMFowWTELMAkGA1UE 48 | BhMCVVMxIDAeBgNVBAoTFyhTVEFHSU5HKSBMZXQncyBFbmNyeXB0MSgwJgYDVQQD 49 | Ex8oU1RBR0lORykgQXJ0aWZpY2lhbCBBcHJpY290IFIzMIIBIjANBgkqhkiG9w0B 50 | AQEFAAOCAQ8AMIIBCgKCAQEAu6TR8+74b46mOE1FUwBrvxzEYLck3iasmKrcQkb+ 51 | gy/z9Jy7QNIAl0B9pVKp4YU76JwxF5DOZZhi7vK7SbCkK6FbHlyU5BiDYIxbbfvO 52 | L/jVGqdsSjNaJQTg3C3XrJja/HA4WCFEMVoT2wDZm8ABC1N+IQe7Q6FEqc8NwmTS 53 | nmmRQm4TQvr06DP+zgFK/MNubxWWDSbSKKTH5im5j2fZfg+j/tM1bGaczFWw8/lS 54 | nukyn5J2L+NJYnclzkXoh9nMFnyPmVbfyDPOc4Y25aTzVoeBKXa/cZ5MM+WddjdL 55 | biWvm19f1sYn1aRaAIrkppv7kkn83vcth8XCG39qC2ZvaQIDAQABo4IBEDCCAQww 56 | DgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAS 57 | BgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTecnpI3zHDplDfn4Uj31c3S10u 58 | ZTAfBgNVHSMEGDAWgBS182Xy/rAKkh/7PH3zRKCsYyXDFDA2BggrBgEFBQcBAQQq 59 | MCgwJgYIKwYBBQUHMAKGGmh0dHA6Ly9zdGcteDEuaS5sZW5jci5vcmcvMCsGA1Ud 60 | HwQkMCIwIKAeoByGGmh0dHA6Ly9zdGcteDEuYy5sZW5jci5vcmcvMCIGA1UdIAQb 61 | MBkwCAYGZ4EMAQIBMA0GCysGAQQBgt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCN 62 | DLam9yN0EFxxn/3p+ruWO6n/9goCAM5PT6cC6fkjMs4uas6UGXJjr5j7PoTQf3C1 63 | vuxiIGRJC6qxV7yc6U0X+w0Mj85sHI5DnQVWN5+D1er7mp13JJA0xbAbHa3Rlczn 64 | y2Q82XKui8WHuWra0gb2KLpfboYj1Ghgkhr3gau83pC/WQ8HfkwcvSwhIYqTqxoZ 65 | Uq8HIf3M82qS9aKOZE0CEmSyR1zZqQxJUT7emOUapkUN9poJ9zGc+FgRZvdro0XB 66 | yphWXDaqMYph0DxW/10ig5j4xmmNDjCRmqIKsKoWA52wBTKKXK1na2ty/lW5dhtA 67 | xkz5rVZFd4sgS4J0O+zm6d5GRkWsNJ4knotGXl8vtS3X40KXeb3A5+/3p0qaD215 68 | Xq8oSNORfB2oI1kQuyEAJ5xvPTdfwRlyRG3lFYodrRg6poUBD/8fNTXMtzydpRgy 69 | zUQZh/18F6B/iW6cbiRN9r2Hkh05Om+q0/6w0DdZe+8YrNpfhSObr/1eVZbKGMIY 70 | qKmyZbBNu5ysENIK5MPc14mUeKmFjpN840VR5zunoU52lqpLDua/qIM8idk86xGW 71 | xx2ml43DO/Ya/tVZVok0mO0TUjzJIfPqyvr455IsIut4RlCR9Iq0EDTve2/ZwCuG 72 | hSjpTUFGSiQrR2JK2Evp+o6AETUkBCO1aw0PpQBPDQ== 73 | -----END CERTIFICATE----- 74 | 75 | -----BEGIN CERTIFICATE----- 76 | MIIFVDCCBDygAwIBAgIRAO1dW8lt+99NPs1qSY3Rs8cwDQYJKoZIhvcNAQELBQAw 77 | cTELMAkGA1UEBhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBTZWN1 78 | cml0eSBSZXNlYXJjaCBHcm91cDEtMCsGA1UEAxMkKFNUQUdJTkcpIERvY3RvcmVk 79 | IER1cmlhbiBSb290IENBIFgzMB4XDTIxMDEyMDE5MTQwM1oXDTI0MDkzMDE4MTQw 80 | M1owZjELMAkGA1UEBhMCVVMxMzAxBgNVBAoTKihTVEFHSU5HKSBJbnRlcm5ldCBT 81 | ZWN1cml0eSBSZXNlYXJjaCBHcm91cDEiMCAGA1UEAxMZKFNUQUdJTkcpIFByZXRl 82 | bmQgUGVhciBYMTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALbagEdD 83 | Ta1QgGBWSYkyMhscZXENOBaVRTMX1hceJENgsL0Ma49D3MilI4KS38mtkmdF6cPW 84 | nL++fgehT0FbRHZgjOEr8UAN4jH6omjrbTD++VZneTsMVaGamQmDdFl5g1gYaigk 85 | kmx8OiCO68a4QXg4wSyn6iDipKP8utsE+x1E28SA75HOYqpdrk4HGxuULvlr03wZ 86 | GTIf/oRt2/c+dYmDoaJhge+GOrLAEQByO7+8+vzOwpNAPEx6LW+crEEZ7eBXih6V 87 | P19sTGy3yfqK5tPtTdXXCOQMKAp+gCj/VByhmIr+0iNDC540gtvV303WpcbwnkkL 88 | YC0Ft2cYUyHtkstOfRcRO+K2cZozoSwVPyB8/J9RpcRK3jgnX9lujfwA/pAbP0J2 89 | UPQFxmWFRQnFjaq6rkqbNEBgLy+kFL1NEsRbvFbKrRi5bYy2lNms2NJPZvdNQbT/ 90 | 2dBZKmJqxHkxCuOQFjhJQNeO+Njm1Z1iATS/3rts2yZlqXKsxQUzN6vNbD8KnXRM 91 | EeOXUYvbV4lqfCf8mS14WEbSiMy87GB5S9ucSV1XUrlTG5UGcMSZOBcEUpisRPEm 92 | QWUOTWIoDQ5FOia/GI+Ki523r2ruEmbmG37EBSBXdxIdndqrjy+QVAmCebyDx9eV 93 | EGOIpn26bW5LKerumJxa/CFBaKi4bRvmdJRLAgMBAAGjgfEwge4wDgYDVR0PAQH/ 94 | BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFLXzZfL+sAqSH/s8ffNE 95 | oKxjJcMUMB8GA1UdIwQYMBaAFAhX2onHolN5DE/d4JCPdLriJ3NEMDgGCCsGAQUF 96 | BwEBBCwwKjAoBggrBgEFBQcwAoYcaHR0cDovL3N0Zy1kc3QzLmkubGVuY3Iub3Jn 97 | LzAtBgNVHR8EJjAkMCKgIKAehhxodHRwOi8vc3RnLWRzdDMuYy5sZW5jci5vcmcv 98 | MCIGA1UdIAQbMBkwCAYGZ4EMAQIBMA0GCysGAQQBgt8TAQEBMA0GCSqGSIb3DQEB 99 | CwUAA4IBAQB7tR8B0eIQSS6MhP5kuvGth+dN02DsIhr0yJtk2ehIcPIqSxRRmHGl 100 | 4u2c3QlvEpeRDp2w7eQdRTlI/WnNhY4JOofpMf2zwABgBWtAu0VooQcZZTpQruig 101 | F/z6xYkBk3UHkjeqxzMN3d1EqGusxJoqgdTouZ5X5QTTIee9nQ3LEhWnRSXDx7Y0 102 | ttR1BGfcdqHopO4IBqAhbkKRjF5zj7OD8cG35omywUbZtOJnftiI0nFcRaxbXo0v 103 | oDfLD0S6+AC2R3tKpqjkNX6/91hrRFglUakyMcZU/xleqbv6+Lr3YD8PsBTub6lI 104 | oZ2lS38fL18Aon458fbc0BPHtenfhKj5 105 | -----END CERTIFICATE----- 106 | EOF 107 | 108 | # 1. end-entity 109 | # 2. s=(STAGING) Artificial Apricot R3, i=(STAGING) Pretend Pear X1 110 | TEST_CHAIN_2 = <<~EOF 111 | -----BEGIN CERTIFICATE----- 112 | MIIFXDCCBESgAwIBAgITAPpyplhpKssE1b2toRhXUlfWbzANBgkqhkiG9w0BAQsF 113 | ADBZMQswCQYDVQQGEwJVUzEgMB4GA1UEChMXKFNUQUdJTkcpIExldCdzIEVuY3J5 114 | cHQxKDAmBgNVBAMTHyhTVEFHSU5HKSBBcnRpZmljaWFsIEFwcmljb3QgUjMwHhcN 115 | MjExMDMxMTkwMTA5WhcNMjIwMTI5MTkwMTA4WjAeMRwwGgYDVQQDExNkZXYyMDIx 116 | MTEwMWEuMHcwLmNvMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3TM2 117 | 0x1kfkQB17D6HwgsZIgc1pWpRQpxKsutu+9ugWPpkHbQo55xiageBoyFEu8lh5g0 118 | WsQ8w8aPobZK/GytQvPinXLGwkByB9thuZBqaxijma1Y9sfozts0aqH4ehrdrMyi 119 | I6aZ5RsOn9dV3t6zXlDcl2ZebPcmZlTHZQJrzBHuIfBflDZbqGef7FdtasDfZGra 120 | uqXEUeG3++yiBihQPwJDH+8t3rF1Ijjj2YRGrYmkucLI7a21abkyeYd1LUZs/U9Z 121 | uTadT0YCr8TYmSssF67P5wSjSrsHm9KXjLwn6JJU5dC4qJFni/wD0FluQnVkQZgP 122 | V5i6q+S2Rn06i1/NgQIDAQABo4ICVjCCAlIwDgYDVR0PAQH/BAQDAgWgMB0GA1Ud 123 | JQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAAMB0GA1UdDgQW 124 | BBRbuPijqD9LoqIMoyUb3QtMXkf6QDAfBgNVHSMEGDAWgBTecnpI3zHDplDfn4Uj 125 | 31c3S10uZTBdBggrBgEFBQcBAQRRME8wJQYIKwYBBQUHMAGGGWh0dHA6Ly9zdGct 126 | cjMuby5sZW5jci5vcmcwJgYIKwYBBQUHMAKGGmh0dHA6Ly9zdGctcjMuaS5sZW5j 127 | ci5vcmcvMB4GA1UdEQQXMBWCE2RldjIwMjExMTAxYS4wdzAuY28wTAYDVR0gBEUw 128 | QzAIBgZngQwBAgEwNwYLKwYBBAGC3xMBAQEwKDAmBggrBgEFBQcCARYaaHR0cDov 129 | L2Nwcy5sZXRzZW5jcnlwdC5vcmcwggEEBgorBgEEAdZ5AgQCBIH1BIHyAPAAdgCw 130 | zIPlpfl9a698CcwoSQSHKsfoixMsY1C3xv0m4WxsdwAAAXzX8QZIAAAEAwBHMEUC 131 | IQCyLDw5DX5cFUyz7o8QjD1/s5vpPzOLXQE6UcCBMXp4NAIgcLOw3O50DZ0nK2rD 132 | MSiGTK5cdQTRcwqQcltcxBeDgXsAdgDdmTT8peckgMlWaH2BNJkISbJJ97Vp2Me8 133 | qz9cwfNuZAAAAXzX8QgeAAAEAwBHMEUCIQDK5IiE6LksDEoQ+fm+obf+dXD8yvbE 134 | hDgDZQMgkwvTEgIgEGsyglHs6V7X6keAKMmnAqT862tfo1+TCTeBnH22Oh0wDQYJ 135 | KoZIhvcNAQELBQADggEBAByZRHXVDp1VeZAlLeZS/2bL53hxKkGaUdSXULU2VVag 136 | jrehvdEsLHJfmm5i70F0SyWpPW4kDVP1tUxQ8uqPGkwVS53cldDRZr5dAM0TTuGh 137 | O4dTPOj8ziGfwdbD7gkHmgpDUR97pepyfOVgTGY3VaPVDnBfWpVaGZR+79BJc6qu 138 | dpq74CwWdrDP3jKXeeahvIgqZumUq6pC5CGY/k4GRdfW+KCFk0PqZH75Prxj7L+I 139 | CqgmIJ4t9U/czcBkA3sFxNGkZR9d8B6xHWYesvetiEah5tDIWybn9kEwqEWzXm4S 140 | 3PBPxeNRAeQxux460FHiLk/CXllnGSlU9yMKa++hw14= 141 | -----END CERTIFICATE----- 142 | 143 | -----BEGIN CERTIFICATE----- 144 | MIIFWzCCA0OgAwIBAgIQTfQrldHumzpMLrM7jRBd1jANBgkqhkiG9w0BAQsFADBm 145 | MQswCQYDVQQGEwJVUzEzMDEGA1UEChMqKFNUQUdJTkcpIEludGVybmV0IFNlY3Vy 146 | aXR5IFJlc2VhcmNoIEdyb3VwMSIwIAYDVQQDExkoU1RBR0lORykgUHJldGVuZCBQ 147 | ZWFyIFgxMB4XDTIwMDkwNDAwMDAwMFoXDTI1MDkxNTE2MDAwMFowWTELMAkGA1UE 148 | BhMCVVMxIDAeBgNVBAoTFyhTVEFHSU5HKSBMZXQncyBFbmNyeXB0MSgwJgYDVQQD 149 | Ex8oU1RBR0lORykgQXJ0aWZpY2lhbCBBcHJpY290IFIzMIIBIjANBgkqhkiG9w0B 150 | AQEFAAOCAQ8AMIIBCgKCAQEAu6TR8+74b46mOE1FUwBrvxzEYLck3iasmKrcQkb+ 151 | gy/z9Jy7QNIAl0B9pVKp4YU76JwxF5DOZZhi7vK7SbCkK6FbHlyU5BiDYIxbbfvO 152 | L/jVGqdsSjNaJQTg3C3XrJja/HA4WCFEMVoT2wDZm8ABC1N+IQe7Q6FEqc8NwmTS 153 | nmmRQm4TQvr06DP+zgFK/MNubxWWDSbSKKTH5im5j2fZfg+j/tM1bGaczFWw8/lS 154 | nukyn5J2L+NJYnclzkXoh9nMFnyPmVbfyDPOc4Y25aTzVoeBKXa/cZ5MM+WddjdL 155 | biWvm19f1sYn1aRaAIrkppv7kkn83vcth8XCG39qC2ZvaQIDAQABo4IBEDCCAQww 156 | DgYDVR0PAQH/BAQDAgGGMB0GA1UdJQQWMBQGCCsGAQUFBwMCBggrBgEFBQcDATAS 157 | BgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBTecnpI3zHDplDfn4Uj31c3S10u 158 | ZTAfBgNVHSMEGDAWgBS182Xy/rAKkh/7PH3zRKCsYyXDFDA2BggrBgEFBQcBAQQq 159 | MCgwJgYIKwYBBQUHMAKGGmh0dHA6Ly9zdGcteDEuaS5sZW5jci5vcmcvMCsGA1Ud 160 | HwQkMCIwIKAeoByGGmh0dHA6Ly9zdGcteDEuYy5sZW5jci5vcmcvMCIGA1UdIAQb 161 | MBkwCAYGZ4EMAQIBMA0GCysGAQQBgt8TAQEBMA0GCSqGSIb3DQEBCwUAA4ICAQCN 162 | DLam9yN0EFxxn/3p+ruWO6n/9goCAM5PT6cC6fkjMs4uas6UGXJjr5j7PoTQf3C1 163 | vuxiIGRJC6qxV7yc6U0X+w0Mj85sHI5DnQVWN5+D1er7mp13JJA0xbAbHa3Rlczn 164 | y2Q82XKui8WHuWra0gb2KLpfboYj1Ghgkhr3gau83pC/WQ8HfkwcvSwhIYqTqxoZ 165 | Uq8HIf3M82qS9aKOZE0CEmSyR1zZqQxJUT7emOUapkUN9poJ9zGc+FgRZvdro0XB 166 | yphWXDaqMYph0DxW/10ig5j4xmmNDjCRmqIKsKoWA52wBTKKXK1na2ty/lW5dhtA 167 | xkz5rVZFd4sgS4J0O+zm6d5GRkWsNJ4knotGXl8vtS3X40KXeb3A5+/3p0qaD215 168 | Xq8oSNORfB2oI1kQuyEAJ5xvPTdfwRlyRG3lFYodrRg6poUBD/8fNTXMtzydpRgy 169 | zUQZh/18F6B/iW6cbiRN9r2Hkh05Om+q0/6w0DdZe+8YrNpfhSObr/1eVZbKGMIY 170 | qKmyZbBNu5ysENIK5MPc14mUeKmFjpN840VR5zunoU52lqpLDua/qIM8idk86xGW 171 | xx2ml43DO/Ya/tVZVok0mO0TUjzJIfPqyvr455IsIut4RlCR9Iq0EDTve2/ZwCuG 172 | hSjpTUFGSiQrR2JK2Evp+o6AETUkBCO1aw0PpQBPDQ== 173 | -----END CERTIFICATE----- 174 | EOF 175 | 176 | let(:test_chain_1) { described_class.new(TEST_CHAIN_1) } 177 | let(:test_chain_2) { described_class.new(TEST_CHAIN_2) } 178 | 179 | def subject_cn(cert) 180 | cert.subject.to_a.assoc('CN')[1] 181 | end 182 | 183 | describe "#top" do 184 | it "returns a last certificate of given and constructed chain" do 185 | expect(subject_cn(test_chain_1.top)).to eq '(STAGING) Pretend Pear X1' 186 | expect(subject_cn(test_chain_2.top)).to eq '(STAGING) Artificial Apricot R3' 187 | end 188 | end 189 | 190 | 191 | describe "#match?" do 192 | it "tests against a root issuer name" do 193 | expect(test_chain_1.match?(name: '(STAGING) Doctored Durian Root CA X3')).to eq true 194 | expect(test_chain_2.match?(name: '(STAGING) Pretend Pear X1')).to eq true 195 | 196 | expect(test_chain_1.match?(name: '(STAGING) Pretend Pear X1')).to eq false 197 | expect(test_chain_2.match?(name: '(STAGING) Doctored Durian Root CA X3')).to eq false 198 | 199 | expect(test_chain_1.match?(name: 'dev20211101a.0w0.co')).to eq false 200 | expect(test_chain_2.match?(name: 'dev20211101a.0w0.co')).to eq false 201 | end 202 | 203 | it "tests against a root issuer key id" do 204 | expect(test_chain_1.match?(key_id: '08:57:da:89:c7:a2:53:79:0c:4f:dd:e0:90:8f:74:ba:e2:27:73:44')).to eq true 205 | expect(test_chain_2.match?(key_id: 'b5:f3:65:f2:fe:b0:0a:92:1f:fb:3c:7d:f3:44:a0:ac:63:25:c3:14')).to eq true 206 | 207 | expect(test_chain_1.match?(key_id: 'b5:f3:65:f2:fe:b0:0a:92:1f:fb:3c:7d:f3:44:a0:ac:63:25:c3:14')).to eq false 208 | expect(test_chain_2.match?(key_id: '08:57:da:89:c7:a2:53:79:0c:4f:dd:e0:90:8f:74:ba:e2:27:73:44')).to eq false 209 | 210 | expect(test_chain_1.match?(key_id: '5b:b8:f8:a3:a8:3f:4b:a2:a2:0c:a3:25:1b:dd:0b:4c:5e:47:fa:40')).to eq false 211 | expect(test_chain_2.match?(key_id: '5b:b8:f8:a3:a8:3f:4b:a2:a2:0c:a3:25:1b:dd:0b:4c:5e:47:fa:40')).to eq false 212 | end 213 | end 214 | end 215 | -------------------------------------------------------------------------------- /spec/certificate_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'openssl' 3 | 4 | require 'acmesmith/certificate' 5 | 6 | RSpec.describe Acmesmith::Certificate do 7 | let(:given_certificate) { PEM_LEAF } 8 | let(:given_chain) { PEM_CHAIN } 9 | let(:given_private_key) { PRIVATE_KEY_PEM } 10 | let(:given_passphrase) { nil } 11 | 12 | subject(:certificate) do 13 | described_class.new( 14 | given_certificate, 15 | given_chain, 16 | given_private_key, 17 | given_passphrase, 18 | ) 19 | end 20 | 21 | context "with String" do 22 | let(:given_certificate) { PEM_LEAF } 23 | let(:given_chain) { PEM_CHAIN } 24 | let(:given_private_key) { PRIVATE_KEY_PEM } 25 | 26 | it "works" do 27 | expect(certificate.certificate).to eq(LEAF) 28 | expect(certificate.chain).to eq(CHAIN) 29 | expect(certificate.private_key).to be_a(OpenSSL::PKey::RSA) 30 | expect(certificate.private_key.to_pem).to eq(PRIVATE_KEY.to_pem) 31 | end 32 | end 33 | 34 | context "with OpenSSL" do 35 | let(:given_certificate) { LEAF } 36 | let(:given_chain) { CHAIN } 37 | let(:given_private_key) { PRIVATE_KEY } 38 | 39 | it "works" do 40 | expect(certificate.certificate).to eq(LEAF) 41 | expect(certificate.chain).to eq(CHAIN) 42 | expect(certificate.private_key).to be_a(OpenSSL::PKey::RSA) 43 | expect(certificate.private_key.to_pem).to eq(PRIVATE_KEY.to_pem) 44 | end 45 | end 46 | 47 | describe "#private_key" do 48 | let(:given_private_key) { PRIVATE_KEY_PEM_ENCRYPTED } 49 | subject(:private_key) { certificate.private_key } 50 | 51 | context "when passphrase is not given" do 52 | let(:given_passphrase) { nil } 53 | 54 | it "raises error" do 55 | expect { subject }.to raise_error(Acmesmith::Certificate::PassphraseRequired) 56 | end 57 | end 58 | 59 | context "when passphrase is given at initialize" do 60 | let(:given_passphrase) { PASSPHRASE } 61 | it "works" do 62 | expect(private_key).to be_a(OpenSSL::PKey::RSA) 63 | expect(private_key.to_pem).to eq(PRIVATE_KEY.to_pem) 64 | end 65 | end 66 | 67 | context "when passphrase is given later" do 68 | let(:given_passphrase) { nil } 69 | 70 | before do 71 | certificate.key_passphrase = PASSPHRASE 72 | end 73 | 74 | it "works" do 75 | expect(private_key).to be_a(OpenSSL::PKey::RSA) 76 | expect(private_key.to_pem).to eq(PRIVATE_KEY.to_pem) 77 | end 78 | end 79 | 80 | context "when passphrase is given twice" do 81 | let(:given_passphrase) { PASSPHRASE } 82 | it "raises error" do 83 | expect { certificate.key_passphrase = PASSPHRASE }.to raise_error(Acmesmith::Certificate::PrivateKeyDecrypted) 84 | end 85 | end 86 | end 87 | 88 | describe "#export" do 89 | let(:export_passphrase) { nil } 90 | subject(:export) { certificate.export(export_passphrase) } 91 | 92 | it "works" do 93 | expect(export).to be_a(Acmesmith::Certificate::CertificateExport) 94 | expect(export.certificate).to eq(PEM_LEAF) 95 | expect(export.chain).to eq(PEM_CHAIN) 96 | expect(export.fullchain).to eq(PEM_LEAF + PEM_CHAIN) 97 | expect(export.private_key).to eq(PRIVATE_KEY.to_pem) 98 | end 99 | 100 | context "with passphrase" do 101 | let(:export_passphrase) { PASSPHRASE } 102 | it "works" do 103 | expect(export).to be_a(Acmesmith::Certificate::CertificateExport) 104 | expect(export.certificate).to eq(PEM_LEAF) 105 | expect(export.chain).to eq(PEM_CHAIN) 106 | expect(export.fullchain).to eq(PEM_LEAF + PEM_CHAIN) 107 | expect(export.private_key).to be_a(String) 108 | expect { OpenSSL::PKey::RSA.new(export.private_key, '') }.to raise_error(OpenSSL::PKey::RSAError) 109 | expect(OpenSSL::PKey::RSA.new(export.private_key, export_passphrase).to_pem).to eq(PRIVATE_KEY.to_pem) 110 | end 111 | end 112 | end 113 | 114 | describe "#fullchain" do 115 | subject(:fullchain) { certificate.fullchain } 116 | it { is_expected.to eq("#{PEM_LEAF.chomp}\n#{PEM_CHAIN.chomp}\n") } 117 | end 118 | 119 | describe "#issuer_pems" do 120 | subject(:fullchain) { certificate.issuer_pems } 121 | it { is_expected.to eq(PEM_CHAIN) } 122 | end 123 | 124 | describe "#common_name" do 125 | subject(:fullchain) { certificate.common_name } 126 | it { is_expected.to eq("acmesmith-dev-20200512j.lo.sorah.jp") } 127 | end 128 | 129 | describe "#sans" do 130 | subject(:fullchain) { certificate.sans } 131 | it { is_expected.to eq(['acmesmith-dev-20200512j.lo.sorah.jp', 'acmesmith-dev-20200512l.lo.sorah.jp']) } 132 | end 133 | 134 | describe "#version" do 135 | subject(:fullchain) { certificate.version } 136 | it { is_expected.to eq("20200511-192010_fa5aa9032181a09b2cebec848658eb2c5f79") } 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /spec/chain.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIEqzCCApOgAwIBAgIRAIvhKg5ZRO08VGQx8JdhT+UwDQYJKoZIhvcNAQELBQAw 3 | GjEYMBYGA1UEAwwPRmFrZSBMRSBSb290IFgxMB4XDTE2MDUyMzIyMDc1OVoXDTM2 4 | MDUyMzIyMDc1OVowIjEgMB4GA1UEAwwXRmFrZSBMRSBJbnRlcm1lZGlhdGUgWDEw 5 | ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDtWKySDn7rWZc5ggjz3ZB0 6 | 8jO4xti3uzINfD5sQ7Lj7hzetUT+wQob+iXSZkhnvx+IvdbXF5/yt8aWPpUKnPym 7 | oLxsYiI5gQBLxNDzIec0OIaflWqAr29m7J8+NNtApEN8nZFnf3bhehZW7AxmS1m0 8 | ZnSsdHw0Fw+bgixPg2MQ9k9oefFeqa+7Kqdlz5bbrUYV2volxhDFtnI4Mh8BiWCN 9 | xDH1Hizq+GKCcHsinDZWurCqder/afJBnQs+SBSL6MVApHt+d35zjBD92fO2Je56 10 | dhMfzCgOKXeJ340WhW3TjD1zqLZXeaCyUNRnfOmWZV8nEhtHOFbUCU7r/KkjMZO9 11 | AgMBAAGjgeMwgeAwDgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAw 12 | HQYDVR0OBBYEFMDMA0a5WCDMXHJw8+EuyyCm9Wg6MHoGCCsGAQUFBwEBBG4wbDA0 13 | BggrBgEFBQcwAYYoaHR0cDovL29jc3Auc3RnLXJvb3QteDEubGV0c2VuY3J5cHQu 14 | b3JnLzA0BggrBgEFBQcwAoYoaHR0cDovL2NlcnQuc3RnLXJvb3QteDEubGV0c2Vu 15 | Y3J5cHQub3JnLzAfBgNVHSMEGDAWgBTBJnSkikSg5vogKNhcI5pFiBh54DANBgkq 16 | hkiG9w0BAQsFAAOCAgEABYSu4Il+fI0MYU42OTmEj+1HqQ5DvyAeyCA6sGuZdwjF 17 | UGeVOv3NnLyfofuUOjEbY5irFCDtnv+0ckukUZN9lz4Q2YjWGUpW4TTu3ieTsaC9 18 | AFvCSgNHJyWSVtWvB5XDxsqawl1KzHzzwr132bF2rtGtazSqVqK9E07sGHMCf+zp 19 | DQVDVVGtqZPHwX3KqUtefE621b8RI6VCl4oD30Olf8pjuzG4JKBFRFclzLRjo/h7 20 | IkkfjZ8wDa7faOjVXx6n+eUQ29cIMCzr8/rNWHS9pYGGQKJiY2xmVC9h12H99Xyf 21 | zWE9vb5zKP3MVG6neX1hSdo7PEAb9fqRhHkqVsqUvJlIRmvXvVKTwNCP3eCjRCCI 22 | PTAvjV+4ni786iXwwFYNz8l3PmPLCyQXWGohnJ8iBm+5nk7O2ynaPVW0U2W+pt2w 23 | SVuvdDM5zGv2f9ltNWUiYZHJ1mmO97jSY/6YfdOUH66iRtQtDkHBRdkNBsMbD+Em 24 | 2TgBldtHNSJBfB3pm9FblgOcJ0FSWcUDWJ7vO0+NTXlgrRofRT6pVywzxVo6dND0 25 | WzYlTWeUVsO40xJqhgUQRER9YLOLxJ0O6C8i0xFxAMKOtSdodMB3RIwt7RFQ0uyt 26 | n5Z5MqkYhlMI3J1tPRTp1nEt9fyGspBOO05gi148Qasp+3N+svqKomoQglNoAxU= 27 | -----END CERTIFICATE----- 28 | -------------------------------------------------------------------------------- /spec/challenge_responders/route53_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'aws-sdk-route53' 3 | 4 | require 'acmesmith/challenge_responders/route53' 5 | 6 | class Acmesmith::ChallengeResponders::Route53 7 | def sleep(*); end 8 | def puts(*); end 9 | def print(*); end 10 | end 11 | 12 | RSpec.describe Acmesmith::ChallengeResponders::Route53 do 13 | let(:aws_access_key) { nil } 14 | let(:assume_role) { nil } 15 | let(:hosted_zone_map) { {} } 16 | let(:restore_to_original_records) { false } 17 | let(:substitution_map) { {} } 18 | 19 | let(:r53) { double(:route53) } 20 | 21 | subject(:responder) do 22 | described_class.new( 23 | aws_access_key: aws_access_key, 24 | assume_role: assume_role, 25 | hosted_zone_map: hosted_zone_map, 26 | restore_to_original_records: restore_to_original_records, 27 | substitution_map: substitution_map, 28 | ) 29 | end 30 | 31 | 32 | describe ".new" do 33 | context "with no parameters" do 34 | before do 35 | expect(Aws::Route53::Client).to receive(:new).with({region: 'us-east-1'}).and_return(r53) 36 | end 37 | 38 | it "uses SDK default" do 39 | responder 40 | end 41 | end 42 | 43 | context "with aws_access_key" do 44 | let(:aws_access_key) { {'access_key_id' => 'a', 'secret_access_key' => 'b', 'session_token' => 'c'} } 45 | 46 | before do 47 | akia = double(:akia) 48 | allow(Aws::Credentials).to receive(:new).with('a', 'b', 'c').and_return(akia) 49 | expect(Aws::Route53::Client).to receive(:new).with({ 50 | region: 'us-east-1', 51 | credentials: akia, 52 | }).and_return(r53) 53 | end 54 | 55 | it "uses credentials" do 56 | responder 57 | end 58 | end 59 | 60 | context "with assume_role" do 61 | let(:assume_role) { {'role_arn' => 'arn:aws:iam:', 'external_id' => 'external_id', 'role_session_name' => 'session'} } 62 | before do 63 | 64 | sts = double(:sts) 65 | allow(Aws::STS::Client).to receive(:new).with({region: 'us-east-1'}).and_return(sts) 66 | cred = double(:cred) 67 | expect(Aws::AssumeRoleCredentials).to receive(:new).with({ 68 | client: sts, 69 | role_arn: 'arn:aws:iam:', 70 | external_id: 'external_id', 71 | role_session_name: 'session', 72 | }).and_return(cred) 73 | 74 | expect(Aws::Route53::Client).to receive(:new).with({ 75 | region: 'us-east-1', 76 | credentials: cred, 77 | }).and_return(r53) 78 | end 79 | 80 | it "uses credentials" do 81 | responder 82 | end 83 | end 84 | 85 | context "with assume_role and access_key" do 86 | let(:aws_access_key) { {'access_key_id' => 'a', 'secret_access_key' => 'b', 'session_token' => 'c'} } 87 | let(:assume_role) { {'role_arn' => 'arn:aws:iam:', 'external_id' => 'external_id', 'role_session_name' => 'session'} } 88 | before do 89 | akia = double(:akia) 90 | allow(Aws::Credentials).to receive(:new).with('a', 'b', 'c').and_return(akia) 91 | sts = double(:sts) 92 | allow(Aws::STS::Client).to receive(:new).with({region: 'us-east-1', credentials: akia}).and_return(sts) 93 | cred = double(:cred) 94 | expect(Aws::AssumeRoleCredentials).to receive(:new).with({ 95 | client: sts, 96 | role_arn: 'arn:aws:iam:', 97 | external_id: 'external_id', 98 | role_session_name: 'session', 99 | }).and_return(cred) 100 | 101 | expect(Aws::Route53::Client).to receive(:new).with({ 102 | region: 'us-east-1', 103 | credentials: cred, 104 | }).and_return(r53) 105 | end 106 | 107 | it "uses credentials" do 108 | responder 109 | end 110 | end 111 | end 112 | 113 | context do 114 | before do 115 | expect(Aws::Route53::Client).to receive(:new).with({region: 'us-east-1'}).and_return(r53) 116 | end 117 | 118 | let(:list_hosted_zones) do 119 | [ 120 | *%w(example.com corp.example.com example.org).map do |_| 121 | Aws::Route53::Types::HostedZone.new(name: "#{_}.", id: "/hostedzone/#{_}", config: Aws::Route53::Types::HostedZoneConfig.new(private_zone: false)) 122 | end, 123 | Aws::Route53::Types::HostedZone.new(name: "example.net.", id: "/hostedzone/example.net-true", config: Aws::Route53::Types::HostedZoneConfig.new(private_zone: false)), 124 | Aws::Route53::Types::HostedZone.new(name: "example.net.", id: "/hostedzone/example.net-dummy", config: Aws::Route53::Types::HostedZoneConfig.new(private_zone: false)), 125 | ] 126 | end 127 | 128 | before do 129 | allow(r53).to receive(:list_hosted_zones).and_return([Aws::Route53::Types::ListHostedZonesResponse.new( 130 | hosted_zones: list_hosted_zones, 131 | )]) 132 | end 133 | 134 | def double_challenge() 135 | double(:challenge, record_name: '_acme-challenge', record_type: 'TXT', record_content: SecureRandom.urlsafe_base64(8)) 136 | end 137 | 138 | def change_object(action:, name:, challenge:) 139 | { 140 | action: action, 141 | resource_record_set: { 142 | name: "_acme-challenge.#{name}", 143 | ttl: 5, 144 | type: 'TXT', 145 | resource_records: [ 146 | { 147 | value: %("#{challenge.record_content}"), 148 | }, 149 | ], 150 | }, 151 | } 152 | end 153 | 154 | def expect_change_rrset(hosted_zone_id:, changes:, comment:, wait: true) 155 | @change_id ||= 0 156 | @change_id += 1 157 | expect(r53).to receive(:change_resource_record_sets).with( 158 | hosted_zone_id: hosted_zone_id, 159 | change_batch: { 160 | changes: changes, 161 | comment: comment, 162 | }, 163 | ).and_return(Aws::Route53::Types::ChangeResourceRecordSetsResponse.new( 164 | change_info: Aws::Route53::Types::ChangeInfo.new(id: "/change/#{@change_id}", status: 'PENDING'), 165 | )) 166 | if wait 167 | expect(r53).to receive(:get_change).with(id: "/change/#{@change_id}").and_return( 168 | Aws::Route53::Types::GetChangeResponse.new( 169 | change_info: Aws::Route53::Types::ChangeInfo.new(id: "/change/#{@change_id}", status: 'INSYNC') 170 | ) 171 | ) 172 | end 173 | end 174 | 175 | describe "#respond_all" do 176 | subject(:respond_all) { responder.respond_all(*domain_and_challenges) } 177 | 178 | context "for single hosted zone (apex)" do 179 | let(:domain) { 'corp.example.com' } 180 | let(:challenge) { double_challenge } 181 | let(:domain_and_challenges) { [ [domain, challenge] ] } 182 | 183 | before do 184 | expect_change_rrset( 185 | hosted_zone_id: '/hostedzone/corp.example.com', 186 | comment: 'ACME challenge response ', 187 | changes: [ 188 | change_object(action: 'UPSERT', name: domain, challenge: challenge), 189 | ], 190 | ) 191 | end 192 | 193 | it "works" do 194 | respond_all 195 | end 196 | end 197 | 198 | context "for single hosted zone" do 199 | let(:domain_and_challenges) do 200 | [ 201 | ['akane.example.com', double_challenge], 202 | ['yaeka.example.com', double_challenge], 203 | ] 204 | end 205 | 206 | before do 207 | expect_change_rrset( 208 | hosted_zone_id: '/hostedzone/example.com', 209 | comment: 'ACME challenge response ', 210 | changes: domain_and_challenges.map do |(domain,challenge)| 211 | change_object(action: 'UPSERT', name: domain, challenge: challenge) 212 | end, 213 | ) 214 | end 215 | 216 | it "works" do 217 | respond_all 218 | end 219 | end 220 | 221 | context "for multiple hosted zones" do 222 | let(:domain_and_challenges) do 223 | [ 224 | ['ibuki.example.com', double_challenge], 225 | ['kanade.corp.example.com', double_challenge], 226 | ] 227 | end 228 | 229 | before do 230 | expect_change_rrset( 231 | hosted_zone_id: '/hostedzone/example.com', 232 | comment: 'ACME challenge response ', 233 | changes: [ 234 | change_object(action: 'UPSERT', name: domain_and_challenges[0][0], challenge: domain_and_challenges[0][1]), 235 | ], 236 | ) 237 | expect_change_rrset( 238 | hosted_zone_id: '/hostedzone/corp.example.com', 239 | comment: 'ACME challenge response ', 240 | changes: [ 241 | change_object(action: 'UPSERT', name: domain_and_challenges[1][0], challenge: domain_and_challenges[1][1]), 242 | ], 243 | ) 244 | end 245 | 246 | it "works" do 247 | respond_all 248 | end 249 | end 250 | 251 | context "when hosted zones are ambiguous" do 252 | let(:domain) { 'botan.example.net' } 253 | let(:challenge) { double_challenge } 254 | let(:domain_and_challenges) { [ [domain, challenge] ] } 255 | 256 | it "raises error" do 257 | expect { respond_all }.to raise_error(Acmesmith::ChallengeResponders::Route53::AmbiguousHostedZones) 258 | end 259 | 260 | 261 | context "with correct hosted zone map" do 262 | let(:hosted_zone_map) { {"example.net" => "/hostedzone/example.net-true"} } 263 | 264 | before do 265 | expect_change_rrset( 266 | hosted_zone_id: '/hostedzone/example.net-true', 267 | comment: 'ACME challenge response ', 268 | changes: [ 269 | change_object(action: 'UPSERT', name: domain, challenge: challenge) 270 | ], 271 | ) 272 | end 273 | 274 | it "works" do 275 | respond_all 276 | end 277 | end 278 | end 279 | end 280 | 281 | describe "#cleanup_all" do 282 | subject(:cleanup_all) { responder.cleanup_all(*domain_and_challenges) } 283 | 284 | context "for single hosted zone (apex)" do 285 | let(:domain) { 'corp.example.com' } 286 | let(:challenge) { double_challenge } 287 | let(:domain_and_challenges) { [ [domain, challenge] ] } 288 | 289 | before do 290 | expect_change_rrset( 291 | hosted_zone_id: '/hostedzone/corp.example.com', 292 | comment: 'ACME challenge response (cleanup)', 293 | changes: [ 294 | change_object(action: 'DELETE', name: domain, challenge: challenge), 295 | ], 296 | wait: false, 297 | ) 298 | end 299 | 300 | it "works" do 301 | cleanup_all 302 | end 303 | end 304 | 305 | context "for single hosted zone" do 306 | let(:domain_and_challenges) do 307 | [ 308 | ['akane.example.com', double_challenge], 309 | ['yaeka.example.com', double_challenge], 310 | ] 311 | end 312 | 313 | before do 314 | expect_change_rrset( 315 | hosted_zone_id: '/hostedzone/example.com', 316 | comment: 'ACME challenge response (cleanup)', 317 | changes: domain_and_challenges.map do |(domain,challenge)| 318 | change_object(action: 'DELETE', name: domain, challenge: challenge) 319 | end, 320 | wait: false, 321 | ) 322 | end 323 | 324 | it "works" do 325 | cleanup_all 326 | end 327 | end 328 | 329 | context "for multiple hosted zones" do 330 | let(:domain_and_challenges) do 331 | [ 332 | ['ibuki.example.com', double_challenge], 333 | ['kanade.corp.example.com', double_challenge], 334 | ] 335 | end 336 | 337 | before do 338 | expect_change_rrset( 339 | hosted_zone_id: '/hostedzone/example.com', 340 | comment: 'ACME challenge response (cleanup)', 341 | changes: [ 342 | change_object(action: 'DELETE', name: domain_and_challenges[0][0], challenge: domain_and_challenges[0][1]), 343 | ], 344 | wait: false, 345 | ) 346 | expect_change_rrset( 347 | hosted_zone_id: '/hostedzone/corp.example.com', 348 | comment: 'ACME challenge response (cleanup)', 349 | changes: [ 350 | change_object(action: 'DELETE', name: domain_and_challenges[1][0], challenge: domain_and_challenges[1][1]), 351 | ], 352 | wait: false, 353 | ) 354 | end 355 | 356 | it "works" do 357 | cleanup_all 358 | end 359 | end 360 | 361 | context "when hosted zones are ambiguous" do 362 | let(:domain) { 'botan.example.net' } 363 | let(:challenge) { double_challenge } 364 | let(:domain_and_challenges) { [ [domain, challenge] ] } 365 | 366 | it "raises error" do 367 | expect { cleanup_all }.to raise_error(Acmesmith::ChallengeResponders::Route53::AmbiguousHostedZones) 368 | end 369 | 370 | 371 | context "with correct hosted zone map" do 372 | let(:hosted_zone_map) { {"example.net" => "/hostedzone/example.net-true"} } 373 | 374 | before do 375 | expect_change_rrset( 376 | hosted_zone_id: '/hostedzone/example.net-true', 377 | comment: 'ACME challenge response (cleanup)', 378 | changes: [ 379 | change_object(action: 'DELETE', name: domain, challenge: challenge) 380 | ], 381 | wait: false, 382 | ) 383 | end 384 | 385 | it "works" do 386 | cleanup_all 387 | end 388 | end 389 | end 390 | end 391 | 392 | context "when restore_to_original_records is set" do 393 | let(:restore_to_original_records) { true } 394 | let(:domain_and_challenges) do 395 | [ 396 | ['akane.example.com', double_challenge], 397 | ['yaeka.example.com', double_challenge], 398 | ] 399 | end 400 | 401 | subject(:roundtrip) { 402 | responder.respond_all(*domain_and_challenges) 403 | responder.cleanup_all(*domain_and_challenges) 404 | } 405 | 406 | before do 407 | allow(r53).to receive(:list_resource_record_sets).with( 408 | hosted_zone_id: '/hostedzone/example.com', 409 | start_record_name: '_acme-challenge.akane.example.com.', 410 | start_record_type: nil, 411 | start_record_identifier: nil, 412 | max_items: 10, 413 | ).and_return( 414 | Aws::Route53::Types::ListResourceRecordSetsResponse.new( 415 | resource_record_sets: [ 416 | Aws::Route53::Types::ResourceRecordSet.new( 417 | name: '_acme-challenge.akane.example.com.', 418 | type: 'CNAME', 419 | ttl: 60, 420 | resource_records: [ 421 | Aws::Route53::Types::ResourceRecord.new(value: 'delegated-validation.example.net.'), 422 | ], 423 | ), 424 | ], 425 | next_record_name: '_c.example.com.', 426 | next_record_type: 'A', 427 | next_record_identifier: nil, 428 | ), 429 | ) 430 | 431 | allow(r53).to receive(:list_resource_record_sets).with( 432 | hosted_zone_id: '/hostedzone/example.com', 433 | start_record_name: '_acme-challenge.yaeka.example.com.', 434 | start_record_type: nil, 435 | start_record_identifier: nil, 436 | max_items: 10, 437 | ).and_return( 438 | Aws::Route53::Types::ListResourceRecordSetsResponse.new( 439 | resource_record_sets: [ 440 | Aws::Route53::Types::ResourceRecordSet.new(name: '_b.example.com.'), 441 | ], 442 | next_record_name: '_c.example.com.', 443 | next_record_type: 'A', 444 | next_record_identifier: nil, 445 | ), 446 | ) 447 | 448 | expect_change_rrset( 449 | hosted_zone_id: '/hostedzone/example.com', 450 | comment: 'ACME challenge response ', 451 | changes: [ 452 | { 453 | action: 'DELETE', 454 | resource_record_set: { 455 | name: "_acme-challenge.akane.example.com.", 456 | ttl: 60, 457 | type: 'CNAME', 458 | alias_target: nil, 459 | resource_records: [ 460 | { 461 | value: "delegated-validation.example.net.", 462 | }, 463 | ], 464 | }, 465 | }, 466 | change_object(action: 'UPSERT', name: domain_and_challenges[0][0], challenge: domain_and_challenges[0][1]), 467 | change_object(action: 'UPSERT', name: domain_and_challenges[1][0], challenge: domain_and_challenges[1][1]), 468 | ], 469 | ) 470 | expect_change_rrset( 471 | hosted_zone_id: '/hostedzone/example.com', 472 | comment: 'ACME challenge response (cleanup)', 473 | changes: [ 474 | change_object(action: 'DELETE', name: domain_and_challenges[0][0], challenge: domain_and_challenges[0][1]), 475 | change_object(action: 'DELETE', name: domain_and_challenges[1][0], challenge: domain_and_challenges[1][1]), 476 | { 477 | action: 'CREATE', 478 | resource_record_set: { 479 | name: "_acme-challenge.akane.example.com.", 480 | ttl: 60, 481 | type: 'CNAME', 482 | alias_target: nil, 483 | resource_records: [ 484 | { 485 | value: "delegated-validation.example.net.", 486 | }, 487 | ], 488 | }, 489 | }, 490 | ], 491 | wait: false, 492 | ) 493 | end 494 | 495 | it "restores to original records on cleanup" do 496 | roundtrip 497 | end 498 | end 499 | 500 | context "when substitution_map is set" do 501 | let(:substitution_map) { {"akane.example.com." => "_akane.example.org"} } 502 | let(:domain_and_challenges) do 503 | [ 504 | ['akane.example.com', double_challenge], 505 | ['yaeka.example.com', double_challenge], 506 | ] 507 | end 508 | 509 | subject(:roundtrip) { 510 | responder.respond_all(*domain_and_challenges) 511 | responder.cleanup_all(*domain_and_challenges) 512 | } 513 | 514 | before do 515 | expect_change_rrset( 516 | hosted_zone_id: '/hostedzone/example.org', 517 | comment: 'ACME challenge response ', 518 | changes: [ 519 | change_object(action: 'UPSERT', name: "_akane.example.org", challenge: domain_and_challenges[0][1]), 520 | ], 521 | ) 522 | expect_change_rrset( 523 | hosted_zone_id: '/hostedzone/example.com', 524 | comment: 'ACME challenge response ', 525 | changes: [ 526 | change_object(action: 'UPSERT', name: domain_and_challenges[1][0], challenge: domain_and_challenges[1][1]), 527 | ], 528 | ) 529 | expect_change_rrset( 530 | hosted_zone_id: '/hostedzone/example.org', 531 | comment: 'ACME challenge response (cleanup)', 532 | changes: [ 533 | change_object(action: 'DELETE', name: "_akane.example.org", challenge: domain_and_challenges[0][1]), 534 | ], 535 | wait: false, 536 | ) 537 | expect_change_rrset( 538 | hosted_zone_id: '/hostedzone/example.com', 539 | comment: 'ACME challenge response (cleanup)', 540 | changes: [ 541 | change_object(action: 'DELETE', name: domain_and_challenges[1][0], challenge: domain_and_challenges[1][1]), 542 | ], 543 | wait: false, 544 | ) 545 | end 546 | 547 | it "uses another name for rrset" do 548 | roundtrip 549 | end 550 | end 551 | 552 | end 553 | 554 | 555 | describe "#cap_respond_all?" do 556 | subject { responder.cap_respond_all? } 557 | it { is_expected.to eq(true) } 558 | end 559 | 560 | describe "#support?" do 561 | context "dns-01" do 562 | subject { responder.support?('dns-01') } 563 | it { is_expected.to eq(true) } 564 | end 565 | 566 | context "http-01" do 567 | subject { responder.support?('http-01') } 568 | it { is_expected.to eq(false) } 569 | end 570 | end 571 | end 572 | -------------------------------------------------------------------------------- /spec/integration/pebble/integration_spec_config.yml: -------------------------------------------------------------------------------- 1 | directory: https://localhost:14000/dir 2 | bad_nonce_retry: 5 3 | connection_options: 4 | :ssl: 5 | :ca_file: tmp/pebble.minica.crt 6 | storage: 7 | type: filesystem 8 | path: tmp/integration-pebble 9 | challenge_responders: 10 | - pebble_challtestsrv_dns: {} 11 | 12 | post_issuing_hooks: 13 | "flag.invalid": 14 | - shell: 15 | command: touch tmp/integration-pebble/flag-${COMMON_NAME} 16 | 17 | passphrase_from_env: true 18 | -------------------------------------------------------------------------------- /spec/integration/pebble/pebble_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'fileutils' 3 | require 'open-uri' 4 | 5 | # https://github.com/letsencrypt/pebble/ 6 | 7 | class PebbleRunner 8 | def self.start 9 | @pebble = spawn(*%w(docker run --net=host --rm letsencrypt/pebble pebble -config /test/config/pebble-config.json -strict -dnsserver 127.0.0.1:8053)) 10 | @challtestsrv = spawn(*%w(docker run --net=host --rm letsencrypt/pebble-challtestsrv pebble-challtestsrv -management :8055 -defaultIPv4 127.0.0.1)) 11 | end 12 | 13 | def self.wait 14 | begin 15 | TCPSocket.open('localhost', 14000) do 16 | end 17 | rescue => e 18 | puts "waiting pebble: #{e}" 19 | sleep 1 20 | retry 21 | end 22 | 23 | begin 24 | TCPSocket.open('localhost', 8055) do 25 | end 26 | rescue => e 27 | puts "waiting challtestsrv: #{e}" 28 | sleep 1 29 | retry 30 | end 31 | end 32 | 33 | def self.stop 34 | [@pebble, @challtestsrv].each do |pid| 35 | next unless pid 36 | Process.kill :TERM, pid 37 | rescue Errno::ESRCH, Errno::ECHILD 38 | end 39 | end 40 | end 41 | 42 | RSpec.describe "Integration with Pebble", integration_pebble: true do 43 | PEBBLE_CONFIG = File.join(__dir__, 'integration_spec_config.yml') 44 | 45 | def cmd(*args) 46 | ['bin/acmesmith', args.first, '-c', PEBBLE_CONFIG, *args[1..-1]] 47 | end 48 | 49 | before(:all) do 50 | FileUtils.rm_rf('./tmp/integration-pebble') 51 | FileUtils.mkdir_p('./tmp/integration-pebble') 52 | 53 | unless File.exist?('./tmp/pebble.minica.crt') 54 | File.write './tmp/pebble.minica.crt', URI.open('https://raw.githubusercontent.com/letsencrypt/pebble/master/test/certs/pebble.minica.pem', 'r', &:read) 55 | end 56 | 57 | PebbleRunner.start if ENV['ACMESMITH_CI_START_PEBBLE'] 58 | PebbleRunner.wait 59 | sleep 5 60 | 61 | system(*cmd("new-account", "mailto:pebble@example.com"), exception: true) 62 | system(*cmd("order", "test.invalid"), exception: true) 63 | system(*cmd("add-san", "test.invalid", "san.invalid"), exception: true) 64 | end 65 | 66 | it "creates account file" do 67 | expect { OpenSSL::PKey::RSA.new(File.read('tmp/integration-pebble/account.pem'), '') }.not_to raise_error 68 | end 69 | 70 | context "test.invalid show-private-key:" do 71 | it "works" do 72 | private_key = OpenSSL::PKey::RSA.new(IO.popen(cmd("show-private-key", "test.invalid"), 'r', &:read), '') 73 | certificate = OpenSSL::X509::Certificate.new(IO.popen(cmd("show-certificate", "--type=certificate", "test.invalid"))) 74 | expect(private_key.public_key.to_pem).to eq(certificate.public_key.to_pem) 75 | end 76 | end 77 | 78 | context "test.invalid current:" do 79 | it "acmesmith current works" do 80 | current = IO.popen(cmd("current", "test.invalid"), 'r', &:read) 81 | version_pem = IO.popen(cmd("show-certificate", "--type=fullchain", "--version=#{current.chomp}", "test.invalid"), 'r', &:read) 82 | current_pem = IO.popen(cmd("show-certificate", "--type=fullchain", "test.invalid"), 'r', &:read) 83 | expect(version_pem).to eq(current_pem) 84 | end 85 | end 86 | 87 | context "test.invalid add-san:" do 88 | it "works" do 89 | versions = IO.popen(cmd("list", "test.invalid"), 'r', &:read) 90 | expect(versions.each_line.count).to eq(2) 91 | 92 | first = IO.popen(cmd("show-certificate", "--version=#{versions.lines.sort[0]}", "--type=certificate", "test.invalid"), 'r', &:read) 93 | current = IO.popen(cmd("show-certificate", "--type=certificate", "test.invalid"), 'r', &:read) 94 | 95 | first_san = OpenSSL::X509::Certificate.new(first).extensions.select { |_| _.oid == 'subjectAltName' }.flat_map do |ext| 96 | ext.value.split(/,\s*/).select { |_| _.start_with?('DNS:') }.map { |_| _[4..-1] } 97 | end 98 | current_san = OpenSSL::X509::Certificate.new(current).extensions.select { |_| _.oid == 'subjectAltName' }.flat_map do |ext| 99 | ext.value.split(/,\s*/).select { |_| _.start_with?('DNS:') }.map { |_| _[4..-1] } 100 | end 101 | 102 | expect(first_san).not_to include('san.invalid') 103 | expect(current_san).to include('san.invalid') 104 | end 105 | end 106 | 107 | context "EC key" do 108 | it "works" do 109 | system(*cmd("order", "ecdsa.invalid", "--key-type", "ec", "--elliptic-curve", "prime256v1"), exception: true) 110 | 111 | certificate = OpenSSL::X509::Certificate.new(IO.popen(cmd("show-certificate", "--type=certificate", "ecdsa.invalid"))) 112 | expect(certificate.public_key.group.curve_name).to eq "prime256v1" 113 | 114 | system(*cmd("add-san", "ecdsa.invalid", "san.invalid"), exception: true) 115 | 116 | certificate = OpenSSL::X509::Certificate.new(IO.popen(cmd("show-certificate", "--type=certificate", "ecdsa.invalid"))) 117 | expect(certificate.public_key.group.curve_name).to eq "prime256v1" # new cert has the same curve 118 | end 119 | end 120 | 121 | context "RSA3072 key" do 122 | it "works" do 123 | system(*cmd("order", "rsa3072.invalid", "--key-type", "rsa", "--rsa-key-size", "3072"), exception: true) 124 | 125 | certificate = OpenSSL::X509::Certificate.new(IO.popen(cmd("show-certificate", "--type=certificate", "rsa3072.invalid"))) 126 | expect(certificate.public_key.n.num_bits).to eq 3072 127 | 128 | system(*cmd("add-san", "rsa3072.invalid", "san.invalid"), exception: true) 129 | 130 | certificate = OpenSSL::X509::Certificate.new(IO.popen(cmd("show-certificate", "--type=certificate", "rsa3072.invalid"))) 131 | expect(certificate.public_key.n.num_bits).to eq 3072 # new cert has the same key length 132 | end 133 | end 134 | 135 | context "post_issue_hooks" do 136 | it "works" do 137 | system(*cmd("order", "flag.invalid"), exception: true) 138 | expect(File.exist?('tmp/integration-pebble/flag-flag.invalid')).to eq(true) 139 | end 140 | end 141 | 142 | after(:all) do 143 | PebbleRunner.stop 144 | end 145 | 146 | end 147 | -------------------------------------------------------------------------------- /spec/leaf.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIFhjCCBG6gAwIBAgITAPpaqQMhgaCbLOvshIZY6yxfeTANBgkqhkiG9w0BAQsF 3 | ADAiMSAwHgYDVQQDDBdGYWtlIExFIEludGVybWVkaWF0ZSBYMTAeFw0yMDA1MTEx 4 | OTIwMTBaFw0yMDA4MDkxOTIwMTBaMC4xLDAqBgNVBAMTI2FjbWVzbWl0aC1kZXYt 5 | MjAyMDA1MTJqLmxvLnNvcmFoLmpwMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB 6 | CgKCAQEA1qBX6JRnVVBdzQwGQTamzN5U1Qu0yx+LuPxoU3OEA3PAMwRTdhfs3dwL 7 | /HVjJimQciLk+U1czyBlODVXAhlr4GuDi3J25tLJWi4of3a3LUZxO8MxDg5ON/MT 8 | ehUOzH5g/W4lD3XCpcjVNHj/mMYPJ/EH45TVGLwQgHcYW0YwaTsAzZkKkwbY7QNr 9 | bS58VCA2f5xX5vT+pLON2i3BQ5luBaau9BASLblgJX+jk+lA+DjUAy/ePT1Cn8jg 10 | f4uTE5mrBCWsTR2RJn7LTxfpUMOfmSRcqEhCintr3mO/WGO9BIAkcOc9vdoYolpw 11 | ecLfySTpwnmDksbuepOII9BpkR1VxQIDAQABo4ICpzCCAqMwDgYDVR0PAQH/BAQD 12 | AgWgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAMBgNVHRMBAf8EAjAA 13 | MB0GA1UdDgQWBBQ5MwqfN0BgvOUZllaoDLNcedaH2jAfBgNVHSMEGDAWgBTAzANG 14 | uVggzFxycPPhLssgpvVoOjB3BggrBgEFBQcBAQRrMGkwMgYIKwYBBQUHMAGGJmh0 15 | dHA6Ly9vY3NwLnN0Zy1pbnQteDEubGV0c2VuY3J5cHQub3JnMDMGCCsGAQUFBzAC 16 | hidodHRwOi8vY2VydC5zdGctaW50LXgxLmxldHNlbmNyeXB0Lm9yZy8wUwYDVR0R 17 | BEwwSoIjYWNtZXNtaXRoLWRldi0yMDIwMDUxMmoubG8uc29yYWguanCCI2FjbWVz 18 | bWl0aC1kZXYtMjAyMDA1MTJsLmxvLnNvcmFoLmpwMEwGA1UdIARFMEMwCAYGZ4EM 19 | AQIBMDcGCysGAQQBgt8TAQEBMCgwJgYIKwYBBQUHAgEWGmh0dHA6Ly9jcHMubGV0 20 | c2VuY3J5cHQub3JnMIIBBgYKKwYBBAHWeQIEAgSB9wSB9ADyAHcAsMyD5aX5fWuv 21 | fAnMKEkEhyrH6IsTLGNQt8b9JuFsbHcAAAFyBWUXmAAABAMASDBGAiEA7JPpPyGl 22 | Hb+1pZC4KmHd8QUnBukXbhKAMCcExtsGMroCIQCW6wWLHrtAgHcc45vtOV2nbqmz 23 | oT2vF4wk6OeURRf8LgB3AMY/IhjDfVamqga1ltqOU9TXFW0em6yORNIgLeZNadnc 24 | AAABcgVlGYEAAAQDAEgwRgIhANUvMknWP8bF4adbek3biyixSnNJSbIPBYcypt3/ 25 | POVrAiEA0goGeSaSniFb+8tTrJU+KnIH+jD9DDE/2EGMIQHWVZ4wDQYJKoZIhvcN 26 | AQELBQADggEBAJq5QFIiM7sRgSMJB+ALcEsVqp089hIpIT4n3ZHzLQieqT77id0a 27 | 4FBecU4pwQfhZXcDRnWA7SgpvKmGb5BWBIUqVNLjRZredJaVPxD8g1iXrD7L3Ldp 28 | 8DoOxYhxZUF7a3gbhHjXmvGb0VFr0iOntspoqX4cEq9jhMaAAhIU+MxlGunZ/zXs 29 | YgHrkBdIU0Fb9QmDldfzCDEvA3+rrew9vZugMm6asUVh9LKOgT5c53Gua6x2zjOy 30 | ZzMuPQwORSRWfI587c9kvL/cOtp6lQBLRFlpU3fE7sKpgyAjTiYo2T+qLA83Ec8M 31 | cFGi7q6D8rF1eV9u6oup5hztuLg0G64JTmA= 32 | -----END CERTIFICATE----- 33 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'acmesmith/config' 3 | require 'openssl' 4 | 5 | ENV['AWS_ACCESS_KEY_ID'] = 'dummy' 6 | ENV['AWS_SECRET_ACCESS_KEY'] = 'dummy' 7 | ENV['AWS_SESSION_TOKEN'] = 'dummy' 8 | 9 | PASSPHRASE = 'tonymoris' 10 | 11 | PEM_LEAF = File.read(File.join(__dir__, 'leaf.pem')) 12 | LEAF = OpenSSL::X509::Certificate.new(PEM_LEAF) 13 | 14 | PEM_CHAIN = File.read(File.join(__dir__, 'chain.pem')) 15 | CHAIN = PEM_CHAIN.each_line.slice_before(/^-----BEGIN CERTIFICATE-----$/).map(&:join).map { |_| OpenSSL::X509::Certificate.new(_) } 16 | 17 | PRIVATE_KEY = OpenSSL::PKey::RSA.generate(1024) # don't use 1024 in wild 18 | PRIVATE_KEY_PEM = PRIVATE_KEY.export 19 | PRIVATE_KEY_PEM_ENCRYPTED = PRIVATE_KEY.export(OpenSSL::Cipher.new('aes-256-cbc'), PASSPHRASE) 20 | 21 | RSpec.configure do |c| 22 | c.filter_run_excluding :integration_pebble 23 | end 24 | -------------------------------------------------------------------------------- /spec/storages/s3_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'aws-sdk-s3' 3 | require 'stringio' 4 | 5 | require 'acmesmith/account_key' 6 | require 'acmesmith/certificate' 7 | require 'acmesmith/storages/base' 8 | require 'acmesmith/storages/s3' 9 | 10 | RSpec.describe Acmesmith::Storages::S3 do 11 | let(:aws_access_key) { nil } 12 | let(:use_kms) { false } 13 | let(:kms_key_id) { nil } 14 | let(:kms_key_id_account) { nil } 15 | let(:kms_key_id_certificate_key) { nil } 16 | let(:pkcs12_passphrase) { nil } 17 | let(:pkcs12_common_names) { nil } 18 | 19 | 20 | let(:s3) { double(:s3) } 21 | 22 | subject(:storage) do 23 | described_class.new( 24 | aws_access_key: aws_access_key, 25 | bucket: 'bucket', 26 | prefix: 'prefix/', 27 | region: 'dummy', 28 | use_kms: use_kms, 29 | kms_key_id: kms_key_id, 30 | kms_key_id_account: kms_key_id_account, 31 | kms_key_id_certificate_key: kms_key_id_certificate_key, 32 | pkcs12_passphrase: pkcs12_passphrase, 33 | pkcs12_common_names: pkcs12_common_names, 34 | ) 35 | end 36 | 37 | describe ".new" do 38 | context "with no parameters" do 39 | before do 40 | expect(Aws::S3::Client).to receive(:new).with({region: 'dummy'}).and_return(s3) 41 | end 42 | 43 | it "uses SDK default" do 44 | storage 45 | end 46 | end 47 | 48 | context "with aws_access_key" do 49 | let(:aws_access_key) { {'access_key_id' => 'a', 'secret_access_key' => 'b', 'session_token' => 'c'} } 50 | 51 | before do 52 | akia = double(:akia) 53 | allow(Aws::Credentials).to receive(:new).with('a', 'b', 'c').and_return(akia) 54 | expect(Aws::S3::Client).to receive(:new).with({ 55 | region: 'dummy', 56 | credentials: akia, 57 | }).and_return(s3) 58 | end 59 | 60 | it "uses credentials" do 61 | storage 62 | end 63 | end 64 | end 65 | 66 | context do 67 | before do 68 | expect(Aws::S3::Client).to receive(:new).with({region: 'dummy'}).and_return(s3) 69 | end 70 | 71 | describe "#get_account_key" do 72 | subject(:account_key) { storage.get_account_key } 73 | 74 | context "when exists" do 75 | before do 76 | expect(s3).to receive(:get_object) 77 | .with({bucket: 'bucket', key: 'prefix/account.pem'}) 78 | .and_return(double(:obj, body: StringIO.new(PRIVATE_KEY_PEM))) 79 | end 80 | 81 | it "returns a key" do 82 | expect(account_key).to be_a(Acmesmith::AccountKey) 83 | expect(account_key.private_key.to_pem).to eq(PRIVATE_KEY_PEM) 84 | end 85 | end 86 | 87 | context "when not exists" do 88 | before do 89 | expect(s3).to receive(:get_object) 90 | .with(bucket: 'bucket', key: 'prefix/account.pem') 91 | .and_raise(Aws::S3::Errors::NoSuchKey.new('','')) 92 | end 93 | 94 | it "returns a key" do 95 | expect { account_key }.to raise_error(Acmesmith::Storages::Base::NotExist) 96 | end 97 | end 98 | end 99 | 100 | describe "#put_account_key" do 101 | subject(:action) { storage.put_account_key(account_key, PASSPHRASE) } 102 | let(:account_key) { double(:account_key) } 103 | let(:use_kms) { false } 104 | 105 | context "when key doesn't exists" do 106 | 107 | before do 108 | expect(s3).to receive(:get_object) 109 | .with({bucket: 'bucket', key: 'prefix/account.pem'}) 110 | .and_raise(Aws::S3::Errors::NoSuchKey.new('','')) 111 | 112 | expect(account_key).to receive(:export).with(PASSPHRASE).and_return(PRIVATE_KEY_PEM_ENCRYPTED) 113 | 114 | kms_options = use_kms ? {server_side_encryption: 'aws:kms'} : {} 115 | kms_options&.merge!(ssekms_key_id: kms_key_id || kms_key_id_account) if kms_key_id || kms_key_id_account 116 | expect(s3).to receive(:put_object) 117 | .with( 118 | { 119 | bucket: 'bucket', 120 | key: 'prefix/account.pem', 121 | body: PRIVATE_KEY_PEM_ENCRYPTED, 122 | content_type: 'application/x-pem-file', 123 | }.merge(kms_options), 124 | ) 125 | .and_return(nil) 126 | end 127 | 128 | 129 | it "puts a key" do 130 | action 131 | end 132 | 133 | context "with kms" do 134 | let(:use_kms) { true } 135 | 136 | it "puts a key" do 137 | action 138 | end 139 | 140 | context "with kms_key_id_acocunt" do 141 | let(:kms_key_id_account) { 'kmskeyidAccount' } 142 | 143 | it "puts a key" do 144 | action 145 | end 146 | end 147 | 148 | context "with kms_key_id" do 149 | let(:kms_key_id) { 'kmskeyid' } 150 | 151 | it "puts a key" do 152 | action 153 | end 154 | end 155 | end 156 | end 157 | 158 | context "when key exists" do 159 | before do 160 | expect(s3).to receive(:get_object) 161 | .with({bucket: 'bucket', key: 'prefix/account.pem'}) 162 | .and_return(double(:obj, body: StringIO.new(PRIVATE_KEY_PEM))) 163 | end 164 | 165 | it "raises error" do 166 | expect { action }.to raise_error(Acmesmith::Storages::Base::AlreadyExist) 167 | end 168 | end 169 | end 170 | 171 | describe "#get_certificate" do 172 | let(:common_name) { 'common-name' } 173 | let(:version) { 'version' } 174 | 175 | subject(:certificate) { storage.get_certificate(common_name, version: version) } 176 | 177 | context "when certificate exists" do 178 | before do 179 | expect(s3).to receive(:get_object) 180 | .with({bucket: 'bucket', key: "prefix/certs/common-name/#{version}/cert.pem"}) 181 | .and_return(double(:obj, body: StringIO.new(PEM_LEAF))) 182 | expect(s3).to receive(:get_object) 183 | .with({bucket: 'bucket', key: "prefix/certs/common-name/#{version}/key.pem"}) 184 | .and_return(double(:obj, body: StringIO.new(PRIVATE_KEY_PEM))) 185 | expect(s3).to receive(:get_object) 186 | .with({bucket: 'bucket', key: "prefix/certs/common-name/#{version}/chain.pem"}) 187 | .and_return(double(:obj, body: StringIO.new(PEM_CHAIN))) 188 | end 189 | 190 | it "loads certificate" do 191 | expect(certificate).to be_a(Acmesmith::Certificate) 192 | expect(certificate.certificate.to_pem).to eq(PEM_LEAF) 193 | expect(certificate.private_key.to_pem).to eq(PRIVATE_KEY_PEM) 194 | expect(certificate.issuer_pems).to eq(PEM_CHAIN) 195 | end 196 | 197 | context "with current version" do 198 | subject(:certificate) { storage.get_certificate(common_name, version: 'current') } 199 | 200 | before do 201 | expect(s3).to receive(:get_object) 202 | .with({bucket: 'bucket', key: "prefix/certs/common-name/current"}) 203 | .and_return(double(:obj, body: StringIO.new(version))) 204 | end 205 | 206 | it "loads certificate" do 207 | expect(certificate).to be_a(Acmesmith::Certificate) 208 | expect(certificate.certificate.to_pem).to eq(PEM_LEAF) 209 | expect(certificate.private_key.to_pem).to eq(PRIVATE_KEY_PEM) 210 | expect(certificate.issuer_pems).to eq(PEM_CHAIN) 211 | end 212 | end 213 | end 214 | 215 | context "when ceritificate not exists" do 216 | before do 217 | expect(s3).to receive(:get_object) 218 | .with({bucket: 'bucket', key: 'prefix/certs/common-name/version/cert.pem'}) 219 | .and_raise(Aws::S3::Errors::NoSuchKey.new('','')) 220 | end 221 | 222 | it "raises error" do 223 | expect { certificate }.to raise_error(Acmesmith::Storages::Base::NotExist) 224 | end 225 | end 226 | 227 | context "when current version not exists" do 228 | subject(:certificate) { storage.get_certificate(common_name, version: 'current') } 229 | 230 | before do 231 | expect(s3).to receive(:get_object) 232 | .with({bucket: 'bucket', key: 'prefix/certs/common-name/current'}) 233 | .and_raise(Aws::S3::Errors::NoSuchKey.new('','')) 234 | end 235 | 236 | it "raises error" do 237 | expect { certificate }.to raise_error(Acmesmith::Storages::Base::NotExist) 238 | end 239 | end 240 | end 241 | 242 | describe "#put_certificate" do 243 | let(:certificate) { double(:certificate, common_name: 'common-name', version: 'version') } 244 | let(:update_current) { false } 245 | 246 | subject(:action) { storage.put_certificate(certificate, PASSPHRASE, update_current: update_current) } 247 | 248 | before do 249 | allow(certificate).to receive(:export).with(PASSPHRASE).and_return(Acmesmith::Certificate::CertificateExport.new( 250 | certificate: 'certificate', 251 | chain: 'chain', 252 | fullchain: 'fullchain', 253 | private_key: 'private_key', 254 | )) 255 | allow(certificate).to receive(:pkcs12).with('pkcs12').and_return(double(:pkcs12, to_der: "pkcs12-der")) 256 | 257 | expect(s3).to receive(:put_object) 258 | .with({bucket: 'bucket', key: 'prefix/certs/common-name/version/cert.pem', body: "certificate\n", content_type: 'application/x-pem-file'}) 259 | .and_return(nil) 260 | expect(s3).to receive(:put_object) 261 | .with({bucket: 'bucket', key: 'prefix/certs/common-name/version/chain.pem', body: "chain\n", content_type: 'application/x-pem-file'}) 262 | .and_return(nil) 263 | expect(s3).to receive(:put_object) 264 | .with({bucket: 'bucket', key: 'prefix/certs/common-name/version/fullchain.pem', body: "fullchain\n", content_type: 'application/x-pem-file'}) 265 | .and_return(nil) 266 | 267 | kms_options = use_kms ? {server_side_encryption: 'aws:kms'} : {} 268 | kms_options&.merge!(ssekms_key_id: kms_key_id || kms_key_id_certificate_key) if kms_key_id || kms_key_id_certificate_key 269 | expect(s3).to receive(:put_object) 270 | .with( 271 | { 272 | bucket: 'bucket', 273 | key: 'prefix/certs/common-name/version/key.pem', 274 | body: "private_key\n", 275 | content_type: 'application/x-pem-file', 276 | }.merge(kms_options), 277 | ) 278 | .and_return(nil) 279 | end 280 | 281 | it "stores certificate" do 282 | action 283 | end 284 | 285 | context "with update_current" do 286 | let(:update_current) { true } 287 | 288 | before do 289 | expect(s3).to receive(:put_object) 290 | .with({bucket: 'bucket', key: 'prefix/certs/common-name/current', body: 'version', content_type: 'text/plain'}) 291 | .and_return(nil) 292 | end 293 | 294 | it "stores certificate" do 295 | action 296 | end 297 | end 298 | 299 | context "with pkcs12" do 300 | let(:pkcs12_passphrase) { 'pkcs12' } 301 | 302 | context "without pkcs12_common_names" do 303 | before do 304 | expect(s3).to receive(:put_object) 305 | .with({bucket: 'bucket', key: 'prefix/certs/common-name/version/cert.p12', body: "pkcs12-der\n", content_type: 'application/x-pkcs12'}) 306 | .and_return(nil) 307 | end 308 | 309 | it "stores certificate" do 310 | action 311 | end 312 | end 313 | 314 | context "with pkcs12_common_names (match)" do 315 | let(:pkcs12_common_names) { ['common-name'] } 316 | 317 | before do 318 | expect(s3).to receive(:put_object) 319 | .with({bucket: 'bucket', key: 'prefix/certs/common-name/version/cert.p12', body: "pkcs12-der\n", content_type: 'application/x-pkcs12'}) 320 | .and_return(nil) 321 | end 322 | 323 | it "stores certificate" do 324 | action 325 | end 326 | end 327 | 328 | context "with pkcs12_common_names (not match)" do 329 | let(:pkcs12_common_names) { ['common-name2'] } 330 | 331 | it "stores certificate" do 332 | action 333 | end 334 | end 335 | end 336 | 337 | context "with kms" do 338 | let(:use_kms) { true } 339 | 340 | it "puts a key" do 341 | action 342 | end 343 | 344 | context "with pkcs12" do 345 | let(:pkcs12_passphrase) { 'pkcs12' } 346 | 347 | before do 348 | expect(s3).to receive(:put_object) 349 | .with({ 350 | bucket: 'bucket', key: 'prefix/certs/common-name/version/cert.p12', body: "pkcs12-der\n", content_type: 'application/x-pkcs12', 351 | server_side_encryption: 'aws:kms', 352 | }) 353 | .and_return(nil) 354 | end 355 | 356 | it "stores certificate" do 357 | action 358 | end 359 | end 360 | 361 | context "with kms_key_id_certificate_key" do 362 | let(:kms_key_id_account) { 'kmskeyidCertificateKey' } 363 | 364 | it "puts a certificate" do 365 | action 366 | end 367 | end 368 | 369 | context "with kms_key_id" do 370 | let(:kms_key_id) { 'kmskeyid' } 371 | 372 | it "puts a certificate" do 373 | action 374 | end 375 | end 376 | end 377 | end 378 | 379 | describe "#get_current_certificate_version" do 380 | let(:common_name) { 'common-name' } 381 | 382 | subject(:version) { storage.get_current_certificate_version(common_name) } 383 | 384 | context "when current exists" do 385 | before do 386 | expect(s3).to receive(:get_object) 387 | .with({bucket: 'bucket', key: "prefix/certs/common-name/current"}) 388 | .and_return(double(:obj, body: StringIO.new('version'))) 389 | end 390 | 391 | it "returns a version" do 392 | expect(version).to eq('version') 393 | end 394 | end 395 | 396 | context "when current version not exists" do 397 | before do 398 | expect(s3).to receive(:get_object) 399 | .with({bucket: 'bucket', key: 'prefix/certs/common-name/current'}) 400 | .and_raise(Aws::S3::Errors::NoSuchKey.new('','')) 401 | end 402 | 403 | it "raises error" do 404 | expect { version}.to raise_error(Acmesmith::Storages::Base::NotExist) 405 | end 406 | end 407 | end 408 | 409 | describe "#list_certificates" do 410 | subject(:list) { storage.list_certificates() } 411 | before do 412 | expect(s3).to receive(:list_objects).with({bucket: 'bucket', delimiter: '/', prefix: 'prefix/certs/'}) 413 | .and_return([Aws::S3::Types::ListObjectsOutput.new( 414 | common_prefixes: %w( 415 | prefix/certs/cert-a/ 416 | prefix/certs/cert-b/ 417 | prefix/certs/cert-c/ 418 | ).map { |pr| Aws::S3::Types::CommonPrefix.new(prefix: pr) }, 419 | )]) 420 | end 421 | 422 | it "returns a list" do 423 | expect(list).to eq(%w(cert-a cert-b cert-c)) 424 | end 425 | end 426 | 427 | describe "#list_certificate_versions" do 428 | subject(:list) { storage.list_certificate_versions('common-name') } 429 | before do 430 | expect(s3).to receive(:list_objects).with({bucket: 'bucket', delimiter: '/', prefix: 'prefix/certs/common-name/'}) 431 | .and_return([Aws::S3::Types::ListObjectsOutput.new( 432 | common_prefixes: %w( 433 | prefix/certs/common-name/a/ 434 | prefix/certs/common-name/b/ 435 | prefix/certs/common-name/c/ 436 | ).map { |pr| Aws::S3::Types::CommonPrefix.new(prefix: pr) }, 437 | )]) 438 | end 439 | 440 | it "returns a list" do 441 | expect(list).to eq(%w(a b c)) 442 | end 443 | end 444 | end 445 | end 446 | --------------------------------------------------------------------------------