acmesmith 2.4.0 → 2.6.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4089bdb36940e87e2f996a6c820a2f06505024f6ae9721c3da332999c2145998
4
- data.tar.gz: 1abf984f4f5d82a0d266bb5aee3905708ba1392238439663af0bc8ac9aa700b0
3
+ metadata.gz: 914be0de4c237bcd0db59a908f8ce70c3f7b364bb2ab0ac25b6e26ff95ecd7bc
4
+ data.tar.gz: 14b96550d4ef0274d1546e78b5121c6b9356e9c71e479a4ed17b4d9ff8fc669e
5
5
  SHA512:
6
- metadata.gz: d1147c0742b2bd14205b89ca76f4b011453d2eeadf0df2aaad094b7e3c32907de322aed48c0d4fce40a4120ddd782c5c67e6da1504644fce3db679b4452ef774
7
- data.tar.gz: e8bc441a9cb9fff624f338fb33946bab80bf742848e695130b17bd807ba8da98d79e5999459ce824cf6ebcdac6206f156026a17adc754aa6bde8356cc3a915f1
6
+ metadata.gz: ce305e972fcb06a4bc97b2ceea2177cd759601c55064dd5e22ea15b0981ea838e7c11d8e5401d08c41ec1f249cfa0d7a6489f67b2c3e52cc8d67bd1871d77e69
7
+ data.tar.gz: dfabb130f347dc7829cbaad1b5de6aca553dfedc7172d53d163736baee2b07b00124ef7f5f31d9270df660f4b9381b05ed6ca6cfa2e9a96163f25d6fa2d83286
@@ -0,0 +1,2 @@
1
+ ko_fi: sorah
2
+ github: [sorah]
@@ -18,9 +18,9 @@ jobs:
18
18
  strategy:
19
19
  fail-fast: false
20
20
  matrix:
21
- ruby-version: ['2.6', '2.7']
21
+ ruby-version: ['3.0', '3.1', '3.2']
22
22
  container:
23
- image: sorah/ruby:${{ matrix.ruby-version }}-dev
23
+ image: public.ecr.aws/sorah/ruby:${{ matrix.ruby-version }}-dev
24
24
  steps:
25
25
 
26
26
  - name: Cache bundled gems
@@ -40,7 +40,7 @@ jobs:
40
40
  strategy:
41
41
  fail-fast: false
42
42
  matrix:
43
- ruby-version: ['2.6', '2.7']
43
+ ruby-version: ['3.0', '3.1', '3.2']
44
44
 
45
45
  # FIXME: once GitHub Actions gains support of adding command line arguments to container
46
46
  # services:
@@ -72,7 +72,7 @@ jobs:
72
72
 
73
73
  - run: 'docker run -d --net=host --rm letsencrypt/pebble pebble -config /test/config/pebble-config.json -strict -dnsserver 127.0.0.1:8053'
74
74
  - run: 'docker run -d --net=host --rm letsencrypt/pebble-challtestsrv pebble-challtestsrv -management :8055 -defaultIPv4 127.0.0.1'
75
- - run: 'docker run --net=host -e CI --rm -v $(pwd):/work -v $(realpath ~/bundle):/bundle sorah/ruby:${{ matrix.ruby-version }}-dev sh -c "cd /work && bundle install --path /bundle && bundle exec rspec -fd -t integration_pebble"'
75
+ - run: 'docker run --net=host -e CI --rm -v $(pwd):/work -v $(realpath ~/bundle):/bundle public.ecr.aws/sorah/ruby:${{ matrix.ruby-version }}-dev sh -c "cd /work && bundle install --path /bundle && bundle exec rspec -fd -t integration_pebble"'
76
76
 
77
77
  docker-build:
78
78
  name: docker-build
data/CHANGELOG.md CHANGED
@@ -1,4 +1,19 @@
1
- ## v2.4.0 (2020-05-12)
1
+ ## v2.6.0 (2023-10-05)
2
+
3
+ ### Enhancement
4
+
5
+ - 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)
6
+ - autorenew: Respect the existing key configuration when regenerating a fresh key pair for renewal. [#58](https://github.com/sorah/acmesmith/pull/58)
7
+
8
+ ## v2.5.0 (2020-10-09)
9
+
10
+ ### Enhancement
11
+
12
+ - Gains `chain_preferences` configuration to choose alternate chain. [#47](https://github.com/sorah/acmesmith/pull/47)
13
+ - route53: Gains `substitution_map` to allow delegation of `_acme-challenge` via predefined CNAME record. [#53](https://github.com/sorah/acmesmith/pull/53)
14
+ - s3: Gains `endpoint` option. [#52](https://github.com/sorah/acmesmith/pull/52)
15
+
16
+ ## v2.4.0 (2020-12-03)
2
17
 
3
18
  ### Enhancement
4
19
 
data/Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- FROM sorah/ruby:2.7-dev as builder
1
+ FROM sorah/ruby:3.2-dev as builder
2
2
 
3
3
  #RUN apt-get update \
4
4
  # && apt-get install -y libmysqlclient-dev git-core \
@@ -12,7 +12,7 @@ RUN sed -i -e 's|Acmesmith::VERSION|"0.0.0"|g' -e '/^require.*acmesmith.version/
12
12
 
13
13
  RUN bundle install --path /gems --jobs 100 --without development
14
14
 
15
- FROM sorah/ruby:2.7
15
+ FROM sorah/ruby:3.2
16
16
 
17
17
  #RUN apt-get update \
18
18
  # && apt-get install -y libmysqlclient20 \
data/Gemfile.lock CHANGED
@@ -1,8 +1,8 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- acmesmith (2.4.0)
5
- acme-client (~> 2)
4
+ acmesmith (2.6.0)
5
+ acme-client (>= 2.0.7, < 3)
6
6
  aws-sdk-acm
7
7
  aws-sdk-route53
8
8
  aws-sdk-s3
@@ -11,55 +11,62 @@ PATH
11
11
  GEM
12
12
  remote: https://rubygems.org/
13
13
  specs:
14
- acme-client (2.0.7)
15
- faraday (>= 0.17, < 2.0.0)
16
- aws-eventstream (1.1.0)
17
- aws-partitions (1.402.0)
18
- aws-sdk-acm (1.38.0)
19
- aws-sdk-core (~> 3, >= 3.109.0)
14
+ acme-client (2.0.14)
15
+ faraday (>= 1.0, < 3.0.0)
16
+ faraday-retry (>= 1.0, < 3.0.0)
17
+ aws-eventstream (1.2.0)
18
+ aws-partitions (1.832.0)
19
+ aws-sdk-acm (1.62.0)
20
+ aws-sdk-core (~> 3, >= 3.184.0)
20
21
  aws-sigv4 (~> 1.1)
21
- aws-sdk-core (3.109.3)
22
+ aws-sdk-core (3.185.0)
22
23
  aws-eventstream (~> 1, >= 1.0.2)
23
- aws-partitions (~> 1, >= 1.239.0)
24
+ aws-partitions (~> 1, >= 1.651.0)
25
+ aws-sigv4 (~> 1.5)
26
+ jmespath (~> 1, >= 1.6.1)
27
+ aws-sdk-kms (1.72.0)
28
+ aws-sdk-core (~> 3, >= 3.184.0)
24
29
  aws-sigv4 (~> 1.1)
25
- jmespath (~> 1.0)
26
- aws-sdk-kms (1.39.0)
27
- aws-sdk-core (~> 3, >= 3.109.0)
30
+ aws-sdk-route53 (1.79.0)
31
+ aws-sdk-core (~> 3, >= 3.184.0)
28
32
  aws-sigv4 (~> 1.1)
29
- aws-sdk-route53 (1.44.0)
30
- aws-sdk-core (~> 3, >= 3.109.0)
31
- aws-sigv4 (~> 1.1)
32
- aws-sdk-s3 (1.86.0)
33
- aws-sdk-core (~> 3, >= 3.109.0)
33
+ aws-sdk-s3 (1.136.0)
34
+ aws-sdk-core (~> 3, >= 3.181.0)
34
35
  aws-sdk-kms (~> 1)
35
- aws-sigv4 (~> 1.1)
36
- aws-sigv4 (1.2.2)
36
+ aws-sigv4 (~> 1.6)
37
+ aws-sigv4 (1.6.0)
37
38
  aws-eventstream (~> 1, >= 1.0.2)
38
- diff-lcs (1.3)
39
- faraday (1.1.0)
40
- multipart-post (>= 1.2, < 3)
41
- ruby2_keywords
42
- jmespath (1.4.0)
43
- mini_portile2 (2.4.0)
44
- multipart-post (2.1.1)
45
- nokogiri (1.10.9)
46
- mini_portile2 (~> 2.4.0)
47
- rake (13.0.1)
48
- rspec (3.9.0)
49
- rspec-core (~> 3.9.0)
50
- rspec-expectations (~> 3.9.0)
51
- rspec-mocks (~> 3.9.0)
52
- rspec-core (3.9.2)
53
- rspec-support (~> 3.9.3)
54
- rspec-expectations (3.9.2)
39
+ base64 (0.1.1)
40
+ diff-lcs (1.4.4)
41
+ faraday (2.7.11)
42
+ base64
43
+ faraday-net_http (>= 2.0, < 3.1)
44
+ ruby2_keywords (>= 0.0.4)
45
+ faraday-net_http (3.0.2)
46
+ faraday-retry (2.2.0)
47
+ faraday (~> 2.0)
48
+ jmespath (1.6.2)
49
+ mini_portile2 (2.8.1)
50
+ nokogiri (1.14.3)
51
+ mini_portile2 (~> 2.8.0)
52
+ racc (~> 1.4)
53
+ racc (1.6.2)
54
+ rake (13.0.6)
55
+ rspec (3.10.0)
56
+ rspec-core (~> 3.10.0)
57
+ rspec-expectations (~> 3.10.0)
58
+ rspec-mocks (~> 3.10.0)
59
+ rspec-core (3.10.1)
60
+ rspec-support (~> 3.10.0)
61
+ rspec-expectations (3.10.1)
55
62
  diff-lcs (>= 1.2.0, < 2.0)
56
- rspec-support (~> 3.9.0)
57
- rspec-mocks (3.9.1)
63
+ rspec-support (~> 3.10.0)
64
+ rspec-mocks (3.10.2)
58
65
  diff-lcs (>= 1.2.0, < 2.0)
59
- rspec-support (~> 3.9.0)
60
- rspec-support (3.9.3)
61
- ruby2_keywords (0.0.2)
62
- thor (1.0.1)
66
+ rspec-support (~> 3.10.0)
67
+ rspec-support (3.10.2)
68
+ ruby2_keywords (0.0.5)
69
+ thor (1.2.2)
63
70
 
64
71
  PLATFORMS
65
72
  ruby
data/README.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # Acmesmith: A simple, effective ACME v2 client to use with many servers and a cloud
2
2
 
3
- ![ci](https://github.com/sorah/acmesmith/workflows/ci/badge.svg?event=push)
3
+ ![ci](https://github.com/sorah/acmesmith/workflows/ci/badge.svg?event=push) <a href='https://ko-fi.com/J3J8CKMUU' target='_blank'><img height='36' style='border:0px;height:36px;' src='https://cdn.ko-fi.com/cdn/kofi3.png?v=3' border='0' alt='Buy Me a Coffee at ko-fi.com' /></a>
4
+
4
5
 
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).
6
7
 
@@ -151,6 +152,30 @@ are configurable per certificate's common-name.
151
152
  - Shell script: [shell](./docs/post_issuing_hooks/shell.md)
152
153
  - Amazon Certificate Manager (ACM): [acm](./docs/post_issuing_hooks/acm.md)
153
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
+
154
179
  ## Vendor dependent notes
155
180
 
156
181
  - [./docs/vendor/aws.md](./docs/vendor/aws.md): IAM and KMS key policies, and some tips
data/acmesmith.gemspec CHANGED
@@ -21,7 +21,7 @@ Acmesmith is an [ACME (Automatic Certificate Management Environment)](https://gi
21
21
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
22
22
  spec.require_paths = ["lib"]
23
23
 
24
- spec.add_dependency "acme-client", '~> 2'
24
+ spec.add_dependency "acme-client", '>= 2.0.7', '< 3'
25
25
  spec.add_dependency "aws-sdk-acm"
26
26
  spec.add_dependency "aws-sdk-route53"
27
27
  spec.add_dependency "aws-sdk-s3"
@@ -24,7 +24,16 @@ challenge_responders:
24
24
 
25
25
  # Restore to original records on cleanup (after domain authorization). Default to false.
26
26
  # Useful when you need to keep existing record as long as possible.
27
- restore_to_original_records: true
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."
28
37
  ```
29
38
 
30
39
  ## IAM Policy
data/docs/vendor/aws.md CHANGED
@@ -20,12 +20,18 @@
20
20
  "Effect": "Allow",
21
21
  "Action": "route53:ChangeResourceRecordSets",
22
22
  "Resource": ["arn:aws:route53:::hostedzone/*"]
23
+ },
24
+ {
25
+ "Effect": "Allow",
26
+ "Action": "route53:ListResourceRecordSets",
27
+ "Resource": ["arn:aws:route53:::hostedzone/*"]
23
28
  }
24
29
  ]
25
30
  }
26
31
  ```
27
32
 
28
- Note: You can limit allowed hosted zone by modifying `Resource` of `route53:ChangeResourceRecordSets`
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
29
35
 
30
36
  ##### Only fetching certificates
31
37
 
@@ -25,7 +25,7 @@ module Acmesmith
25
25
 
26
26
  # @param certificate [OpenSSL::X509::Certificate, String]
27
27
  # @param chain [String, Array<String>, Array<OpenSSL::X509::Certificate>]
28
- # @param private_key [String, OpenSSL::PKey::RSA]
28
+ # @param private_key [String, OpenSSL::PKey::PKey]
29
29
  # @param key_passphrase [String, nil]
30
30
  # @param csr [String, OpenSSL::X509::Request, nil]
31
31
  def initialize(certificate, chain, private_key, key_passphrase = nil, csr = nil)
@@ -66,15 +66,15 @@ module Acmesmith
66
66
  self.key_passphrase = key_passphrase
67
67
  else
68
68
  begin
69
- @private_key = OpenSSL::PKey::RSA.new(@raw_private_key) { nil }
70
- rescue OpenSSL::PKey::RSAError
69
+ @private_key = OpenSSL::PKey.read(@raw_private_key) { nil }
70
+ rescue OpenSSL::PKey::PKeyError
71
71
  # may be encrypted
72
72
  end
73
73
  end
74
- when OpenSSL::PKey::RSA
74
+ when OpenSSL::PKey::PKey
75
75
  @private_key = private_key
76
76
  else
77
- raise TypeError, 'private_key is expected to be a String or OpenSSL::PKey::RSA'
77
+ raise TypeError, 'private_key is expected to be a String or OpenSSL::PKey::PKey'
78
78
  end
79
79
 
80
80
  @csr = case csr
@@ -100,19 +100,24 @@ module Acmesmith
100
100
  def key_passphrase=(pw)
101
101
  raise PrivateKeyDecrypted, 'private_key already given' if @private_key
102
102
 
103
- @private_key = OpenSSL::PKey::RSA.new(@raw_private_key, pw)
103
+ @private_key = OpenSSL::PKey.read(@raw_private_key, pw)
104
104
 
105
105
  @raw_private_key = nil
106
106
  nil
107
107
  end
108
108
 
109
- # @return [OpenSSL::PKey::RSA]
109
+ # @return [OpenSSL::PKey::PKey]
110
110
  # @raise [PassphraseRequired] if private_key is not yet decrypted
111
111
  def private_key
112
112
  return @private_key if @private_key
113
113
  raise PassphraseRequired, 'key_passphrase required'
114
114
  end
115
115
 
116
+ # @return [OpenSSL::PKey::PKey]
117
+ def public_key
118
+ @certificate.public_key
119
+ end
120
+
116
121
  # @return [String] leaf certificate + full certificate chain
117
122
  def fullchain
118
123
  "#{certificate.to_pem}\n#{issuer_pems}".gsub(/\n+/,?\n)
@@ -0,0 +1,126 @@
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<Acmesmith::Config::ChainPreference>]
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
@@ -1,23 +1,18 @@
1
+ require 'acmesmith/domain_name_filter'
2
+
1
3
  module Acmesmith
2
4
  class ChallengeResponderFilter
3
5
  def initialize(responder, subject_name_exact: nil, subject_name_suffix: nil, subject_name_regexp: nil)
4
6
  @responder = responder
5
- @subject_name_exact = subject_name_exact && [*subject_name_exact].flatten.compact
6
- @subject_name_suffix = subject_name_suffix && [*subject_name_suffix].flatten.compact
7
- @subject_name_regexp = subject_name_regexp && [*subject_name_regexp].flatten.compact.map{ |_| Regexp.new(_) }
7
+ @domain_name_filter = DomainNameFilter.new(
8
+ exact: subject_name_exact,
9
+ suffix: subject_name_suffix,
10
+ regexp: subject_name_regexp,
11
+ )
8
12
  end
9
13
 
10
14
  def applicable?(domain)
11
- if @subject_name_exact
12
- return false unless @subject_name_exact.include?(domain)
13
- end
14
- if @subject_name_suffix
15
- return false unless @subject_name_suffix.any? { |suffix| domain.end_with?(suffix) }
16
- end
17
- if @subject_name_regexp
18
- return false unless @subject_name_regexp.any? { |regexp| domain.match?(regexp) }
19
- end
20
- @responder.applicable?(domain)
15
+ @domain_name_filter.match?(domain) && @responder.applicable?(domain)
21
16
  end
22
17
  end
23
18
  end
@@ -17,7 +17,7 @@ module Acmesmith
17
17
  true
18
18
  end
19
19
 
20
- def initialize(aws_access_key: nil, assume_role: nil, hosted_zone_map: {}, restore_to_original_records: false)
20
+ def initialize(aws_access_key: nil, assume_role: nil, hosted_zone_map: {}, restore_to_original_records: false, substitution_map: {})
21
21
  aws_options = {region: 'us-east-1'}.tap do |opt|
22
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
23
  end
@@ -37,9 +37,13 @@ module Acmesmith
37
37
 
38
38
  @restore_to_original_records = restore_to_original_records
39
39
  @original_records = {}
40
+
41
+ @substitution_map = substitution_map.map { |k,v| [canonical_fqdn(k), v] }.to_h
40
42
  end
41
43
 
42
44
  def respond_all(*domain_and_challenges)
45
+ domain_and_challenges = apply_substitution_for_domain_and_challenges(domain_and_challenges)
46
+
43
47
  save_original_records(*domain_and_challenges) if @restore_to_original_records
44
48
 
45
49
  challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) }
@@ -60,6 +64,8 @@ module Acmesmith
60
64
  end
61
65
 
62
66
  def cleanup_all(*domain_and_challenges)
67
+ domain_and_challenges = apply_substitution_for_domain_and_challenges(domain_and_challenges)
68
+
63
69
  challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) }
64
70
 
65
71
  zone_and_batches = challenges_by_hosted_zone.map do |zone_id, dcs|
@@ -264,6 +270,10 @@ module Acmesmith
264
270
  end
265
271
  end
266
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
+
267
277
  def list_existing_rrsets(hosted_zone_id, name)
268
278
  rrsets = []
269
279
  start_record_name = name
@@ -21,26 +21,9 @@ module Acmesmith
21
21
  key
22
22
  end
23
23
 
24
- def order(*identifiers, not_before: nil, not_after: nil)
25
- order = OrderingService.new(
26
- acme: acme,
27
- identifiers: identifiers,
28
- challenge_responder_rules: config.challenge_responders,
29
- not_before: not_before,
30
- not_after: not_after
31
- )
32
- order.perform!
33
- cert = order.certificate
34
-
35
- puts
36
- print " * securing into the storage ..."
37
- storage.put_certificate(cert, certificate_key_passphrase)
38
- puts " [ ok ]"
39
- puts
40
-
41
- execute_post_issue_hooks(cert)
42
-
43
- cert
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)
44
27
  end
45
28
 
46
29
  def authorize(*identifiers)
@@ -148,7 +131,7 @@ module Acmesmith
148
131
  puts " Not valid after: #{not_after}"
149
132
  next unless (cert.certificate.not_after.utc - Time.now.utc) < (days.to_i * 86400)
150
133
  puts " * Renewing: CN=#{cert.common_name}, SANs=#{cert.sans.join(',')}"
151
- order(cert.common_name, *cert.sans)
134
+ order_with_private_key(cert.common_name, *cert.sans, private_key: regenerate_private_key(cert.public_key))
152
135
  end
153
136
  end
154
137
 
@@ -157,7 +140,7 @@ module Acmesmith
157
140
  cert = storage.get_certificate(common_name)
158
141
  sans = cert.sans + add_sans
159
142
  puts " * SANs will be: #{sans.join(?,)}"
160
- order(cert.common_name, *sans)
143
+ order_with_private_key(cert.common_name, *sans, private_key: regenerate_private_key(cert.public_key))
161
144
  end
162
145
 
163
146
  private
@@ -196,5 +179,52 @@ module Acmesmith
196
179
  config['account_key_passphrase']
197
180
  end
198
181
  end
182
+
183
+ def order_with_private_key(*identifiers, private_key:, not_before: nil, not_after: nil)
184
+ order = OrderingService.new(
185
+ acme: acme,
186
+ identifiers: identifiers,
187
+ private_key: private_key,
188
+ challenge_responder_rules: config.challenge_responders,
189
+ chain_preferences: config.chain_preferences,
190
+ not_before: not_before,
191
+ not_after: not_after
192
+ )
193
+ order.perform!
194
+ cert = order.certificate
195
+
196
+ puts
197
+ print " * securing into the storage ..."
198
+ storage.put_certificate(cert, certificate_key_passphrase)
199
+ puts " [ ok ]"
200
+ puts
201
+
202
+ execute_post_issue_hooks(cert)
203
+
204
+ cert
205
+ end
206
+
207
+ def generate_private_key(key_type:, rsa_key_size:, elliptic_curve:)
208
+ case key_type
209
+ when 'rsa'
210
+ OpenSSL::PKey::RSA.generate(rsa_key_size)
211
+ when 'ec'
212
+ OpenSSL::PKey::EC.generate(elliptic_curve)
213
+ else
214
+ raise ArgumentError, "Key type #{key_type} is not supported"
215
+ end
216
+ end
217
+
218
+ # Generate a new key pair with the same type and key size / curve as existing one
219
+ def regenerate_private_key(template)
220
+ case template
221
+ when OpenSSL::PKey::RSA
222
+ OpenSSL::PKey::RSA.generate(template.n.num_bits)
223
+ when OpenSSL::PKey::EC
224
+ OpenSSL::PKey::EC.generate(template.group)
225
+ else
226
+ raise ArgumentError, "Unknown key type: #{template.class}"
227
+ end
228
+ end
199
229
  end
200
230
  end
@@ -32,8 +32,16 @@ module Acmesmith
32
32
 
33
33
  desc "order COMMON_NAME [SAN]", "order certificate for CN +COMMON_NAME+ with SANs +SAN+"
34
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'
35
38
  def order(common_name, *sans)
36
- cert = client.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
+ )
37
45
  if options[:show_certificate]
38
46
  puts cert.certificate.to_text
39
47
  puts cert.certificate.to_pem
@@ -2,11 +2,13 @@ require 'yaml'
2
2
  require 'acmesmith/storages'
3
3
  require 'acmesmith/challenge_responders'
4
4
  require 'acmesmith/challenge_responder_filter'
5
+ require 'acmesmith/domain_name_filter'
5
6
  require 'acmesmith/post_issuing_hooks'
6
7
 
7
8
  module Acmesmith
8
9
  class Config
9
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)
10
12
 
11
13
  def self.load_yaml(path)
12
14
  new YAML.load_file(path)
@@ -29,6 +31,10 @@ module Acmesmith
29
31
  unless @config['directory']
30
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"
31
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
32
38
  end
33
39
 
34
40
  def [](key)
@@ -104,6 +110,20 @@ module Acmesmith
104
110
  end
105
111
  end
106
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
+
107
127
  # def post_actions
108
128
  # end
109
129
  end
@@ -0,0 +1,22 @@
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
@@ -1,5 +1,6 @@
1
1
  require 'acmesmith/authorization_service'
2
2
  require 'acmesmith/certificate'
3
+ require 'acmesmith/certificate_retrieving_service'
3
4
 
4
5
  module Acmesmith
5
6
  class OrderingService
@@ -7,18 +8,22 @@ module Acmesmith
7
8
 
8
9
  # @param acme [Acme::Client] ACME client
9
10
  # @param identifiers [Array<String>] 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
10
12
  # @param challenge_responder_rules [Array<Acmesmith::Config::ChallengeResponderRule>] responders
13
+ # @param chain_preferences [Array<Acmesmith::Config::ChainPreference>] chain_preferences
11
14
  # @param not_before [Time]
12
15
  # @param not_after [Time]
13
- def initialize(acme:, identifiers:, challenge_responder_rules:, not_before: nil, not_after: nil)
16
+ def initialize(acme:, identifiers:, private_key:, challenge_responder_rules:, chain_preferences:, not_before: nil, not_after: nil)
14
17
  @acme = acme
15
18
  @identifiers = identifiers
19
+ @private_key = private_key
16
20
  @challenge_responder_rules = challenge_responder_rules
21
+ @chain_preferences = chain_preferences
17
22
  @not_before = not_before
18
23
  @not_after = not_after
19
24
  end
20
25
 
21
- attr_reader :acme, :identifiers, :challenge_responder_rules, :not_before, :not_after
26
+ attr_reader :acme, :identifiers, :private_key, :challenge_responder_rules, :chain_preferences, :not_before, :not_after
22
27
 
23
28
  def perform!
24
29
  puts "=> Ordering a certificate for the following identifiers:"
@@ -38,7 +43,7 @@ module Acmesmith
38
43
  finalize_order()
39
44
  wait_order_for_complete()
40
45
 
41
- @certificate = Certificate.by_issuance(order.certificate, csr)
46
+ @certificate = Certificate.by_issuance(pem_chain, csr)
42
47
 
43
48
  puts
44
49
  puts "=> Certificate issued"
@@ -77,6 +82,12 @@ module Acmesmith
77
82
  end
78
83
  end
79
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
+
80
91
  def certificate
81
92
  @certificate or raise NotCompleted, "not completed yet"
82
93
  end
@@ -98,7 +109,7 @@ module Acmesmith
98
109
 
99
110
  # @return [Acme::Client::CertificateRequest]
100
111
  def csr
101
- @csr ||= Acme::Client::CertificateRequest.new(subject: { common_name: common_name }, names: sans)
112
+ @csr ||= Acme::Client::CertificateRequest.new(subject: { common_name: common_name }, names: sans, private_key: private_key)
102
113
  end
103
114
  end
104
115
  end
@@ -7,7 +7,7 @@ require 'acmesmith/certificate'
7
7
  module Acmesmith
8
8
  module Storages
9
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)
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
11
  @region = region
12
12
  @bucket = bucket
13
13
  @prefix = prefix
@@ -25,6 +25,7 @@ module Acmesmith
25
25
 
26
26
  @s3 = Aws::S3::Client.new({region: region}.tap do |opt|
27
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
28
29
  end)
29
30
  end
30
31
 
@@ -1,3 +1,3 @@
1
1
  module Acmesmith
2
- VERSION = "2.4.0"
2
+ VERSION = "2.6.0"
3
3
  end
metadata CHANGED
@@ -1,29 +1,35 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acmesmith
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.4.0
4
+ version: 2.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sorah Fukumori
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2020-12-02 00:00:00.000000000 Z
11
+ date: 2023-10-04 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: acme-client
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 2.0.7
20
+ - - "<"
18
21
  - !ruby/object:Gem::Version
19
- version: '2'
22
+ version: '3'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - "~>"
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: 2.0.7
30
+ - - "<"
25
31
  - !ruby/object:Gem::Version
26
- version: '2'
32
+ version: '3'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: aws-sdk-acm
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -136,6 +142,7 @@ extensions: []
136
142
  extra_rdoc_files: []
137
143
  files:
138
144
  - ".dockerignore"
145
+ - ".github/FUNDING.yml"
139
146
  - ".github/stale.yml"
140
147
  - ".github/workflows/build.yml"
141
148
  - ".gitignore"
@@ -162,6 +169,7 @@ files:
162
169
  - lib/acmesmith/account_key.rb
163
170
  - lib/acmesmith/authorization_service.rb
164
171
  - lib/acmesmith/certificate.rb
172
+ - lib/acmesmith/certificate_retrieving_service.rb
165
173
  - lib/acmesmith/challenge_responder_filter.rb
166
174
  - lib/acmesmith/challenge_responders.rb
167
175
  - lib/acmesmith/challenge_responders/base.rb
@@ -171,6 +179,7 @@ files:
171
179
  - lib/acmesmith/client.rb
172
180
  - lib/acmesmith/command.rb
173
181
  - lib/acmesmith/config.rb
182
+ - lib/acmesmith/domain_name_filter.rb
174
183
  - lib/acmesmith/ordering_service.rb
175
184
  - lib/acmesmith/post_issueing_hooks.rb
176
185
  - lib/acmesmith/post_issueing_hooks/base.rb
@@ -206,7 +215,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
206
215
  - !ruby/object:Gem::Version
207
216
  version: '0'
208
217
  requirements: []
209
- rubygems_version: 3.1.2
218
+ rubygems_version: 3.4.6
210
219
  signing_key:
211
220
  specification_version: 4
212
221
  summary: ACME client (Let's encrypt client) to manage certificate in multi server