acmesmith 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,17 +2,32 @@ require 'openssl'
2
2
 
3
3
  module Acmesmith
4
4
  class Certificate
5
+ class PrivateKeyDecrypted < StandardError; end
5
6
  class PassphraseRequired < StandardError; end
6
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<String>]
7
13
  def self.split_pems(pems)
8
14
  pems.each_line.slice_before(/^-----BEGIN CERTIFICATE-----$/).map(&:join)
9
15
  end
10
16
 
17
+ # Return Acmesmith::Certificate by an issued certificate
18
+ # @param pem_chain [String]
19
+ # @param csr [Acme::Client::CertificateRequest]
20
+ # @return [Acmesmith::Certificate]
11
21
  def self.by_issuance(pem_chain, csr)
12
22
  pems = split_pems(pem_chain)
13
- new(*pems, csr.private_key, nil, csr)
23
+ new(pems[0], pems[1..-1], csr.private_key, nil, csr)
14
24
  end
15
25
 
26
+ # @param certificate [OpenSSL::X509::Certificate, String]
27
+ # @param chain [String, Array<String>, Array<OpenSSL::X509::Certificate>]
28
+ # @param private_key [String, OpenSSL::PKey::RSA]
29
+ # @param key_passphrase [String, nil]
30
+ # @param csr [String, OpenSSL::X509::Request, nil]
16
31
  def initialize(certificate, chain, private_key, key_passphrase = nil, csr = nil)
17
32
  @certificate = case certificate
18
33
  when OpenSSL::X509::Certificate
@@ -48,7 +63,7 @@ module Acmesmith
48
63
  when String
49
64
  @raw_private_key = private_key
50
65
  if key_passphrase
51
- self.key_passphrase = key_passphrase
66
+ self.key_passphrase = key_passphrase
52
67
  else
53
68
  begin
54
69
  @private_key = OpenSSL::PKey::RSA.new(@raw_private_key) { nil }
@@ -72,10 +87,18 @@ module Acmesmith
72
87
  end
73
88
  end
74
89
 
75
- attr_reader :certificate, :chain, :csr
90
+ # @return [OpenSSL::X509::Certificate]
91
+ attr_reader :certificate
92
+ # @return [Array<OpenSSL::X509::Certificate>]
93
+ attr_reader :chain
94
+ # @return [OpenSSL::X509::Request]
95
+ attr_reader :csr
76
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
77
100
  def key_passphrase=(pw)
78
- raise 'private_key already given' if @private_key
101
+ raise PrivateKeyDecrypted, 'private_key already given' if @private_key
79
102
 
80
103
  @private_key = OpenSSL::PKey::RSA.new(@raw_private_key, pw)
81
104
 
@@ -83,44 +106,52 @@ module Acmesmith
83
106
  nil
84
107
  end
85
108
 
109
+ # @return [OpenSSL::PKey::RSA]
110
+ # @raise [PassphraseRequired] if private_key is not yet decrypted
86
111
  def private_key
87
112
  return @private_key if @private_key
88
113
  raise PassphraseRequired, 'key_passphrase required'
89
114
  end
90
115
 
116
+ # @return [String] leaf certificate + full certificate chain
91
117
  def fullchain
92
118
  "#{certificate.to_pem}\n#{issuer_pems}".gsub(/\n+/,?\n)
93
119
  end
94
120
 
121
+ # @return [String] issuer certificate chain
95
122
  def issuer_pems
96
123
  chain.map(&:to_pem).join("\n")
97
124
  end
98
125
 
126
+ # @return [String] common name
99
127
  def common_name
100
128
  certificate.subject.to_a.assoc('CN')[1]
101
129
  end
102
130
 
131
+ # @return [Array<String>] Subject Alternative Names (dNSname)
103
132
  def sans
104
133
  certificate.extensions.select { |_| _.oid == 'subjectAltName' }.flat_map do |ext|
105
134
  ext.value.split(/,\s*/).select { |_| _.start_with?('DNS:') }.map { |_| _[4..-1] }
106
135
  end
107
136
  end
108
137
 
138
+ # @return [String] Version string (consists of NotBefore time & certificate serial)
109
139
  def version
110
140
  "#{certificate.not_before.utc.strftime('%Y%m%d-%H%M%S')}_#{certificate.serial.to_i.to_s(16)}"
111
141
  end
112
142
 
143
+ # @return [OpenSSL::PKCS12]
113
144
  def pkcs12(passphrase)
114
- OpenSSL::PKCS12.create(passphrase, common_name, private_key, certificate)
145
+ OpenSSL::PKCS12.create(passphrase, common_name, private_key, certificate, chain)
115
146
  end
116
147
 
148
+ # @return [CertificateExport]
117
149
  def export(passphrase, cipher: OpenSSL::Cipher.new('aes-256-cbc'))
118
- {}.tap do |h|
119
- h[:certificate] = certificate.to_pem
120
- h[:chain] = issuer_pems
121
- h[:fullchain] = fullchain
122
-
123
- h[:private_key] = if passphrase
150
+ CertificateExport.new.tap do |h|
151
+ h.certificate = certificate.to_pem
152
+ h.chain = issuer_pems
153
+ h.fullchain = fullchain
154
+ h.private_key = if passphrase
124
155
  private_key.export(cipher, passphrase)
125
156
  else
126
157
  private_key.export
@@ -0,0 +1,23 @@
1
+ module Acmesmith
2
+ class ChallengeResponderFilter
3
+ def initialize(responder, subject_name_exact: nil, subject_name_suffix: nil, subject_name_regexp: nil)
4
+ @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(_) }
8
+ end
9
+
10
+ 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)
21
+ end
22
+ end
23
+ end
@@ -1,12 +1,19 @@
1
1
  module Acmesmith
2
2
  module ChallengeResponders
3
3
  class Base
4
- # Return supported challenge types
4
+ # @param type [String] ACME challenge type (dns-01, http-01, ...)
5
+ # @return [true, false] true when given challenge type is supported
5
6
  def support?(type)
6
7
  raise NotImplementedError
7
8
  end
8
9
 
9
- # Return 'true' if implements respond_all method.
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
10
17
  def cap_respond_all?
11
18
  false
12
19
  end
@@ -15,6 +22,7 @@ module Acmesmith
15
22
  end
16
23
 
17
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
18
26
  def respond_all(*domain_and_challenges)
19
27
  if cap_respond_all?
20
28
  raise NotImplementedError
@@ -26,6 +34,7 @@ module Acmesmith
26
34
  end
27
35
 
28
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
29
38
  def cleanup_all(*domain_and_challenges)
30
39
  if cap_respond_all?
31
40
  raise NotImplementedError
@@ -0,0 +1,53 @@
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
@@ -17,10 +17,21 @@ module Acmesmith
17
17
  true
18
18
  end
19
19
 
20
- def initialize(aws_access_key: nil, hosted_zone_map: {})
21
- @route53 = Aws::Route53::Client.new({region: 'us-east-1'}.tap do |opt|
20
+ def initialize(aws_access_key: nil, assume_role: nil, hosted_zone_map: {})
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
+ 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
23
33
  end)
34
+
24
35
  @hosted_zone_map = hosted_zone_map
25
36
  @hosted_zone_cache = {}
26
37
  end
@@ -1,5 +1,7 @@
1
1
  require 'acmesmith/account_key'
2
2
  require 'acmesmith/certificate'
3
+ require 'acmesmith/authorization_service'
4
+ require 'acmesmith/ordering_service'
3
5
  require 'acmesmith/save_certificate_service'
4
6
  require 'acme-client'
5
7
 
@@ -11,7 +13,7 @@ module Acmesmith
11
13
 
12
14
  def new_account(contact, tos_agreed: true)
13
15
  key = AccountKey.generate
14
- acme = Acme::Client.new(private_key: key.private_key, directory: config.fetch('directory'))
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)
15
17
  acme.new_account(contact: contact, terms_of_service_agreed: tos_agreed)
16
18
 
17
19
  storage.put_account_key(key, account_key_passphrase)
@@ -20,31 +22,16 @@ module Acmesmith
20
22
  end
21
23
 
22
24
  def order(*identifiers, not_before: nil, not_after: nil)
23
- puts "=> Ordering a certificate for the following identifiers:"
24
- puts
25
- identifiers.each do |id|
26
- puts " * #{id}"
27
- end
28
- puts
29
- puts "=> Generating CSR"
30
- csr = Acme::Client::CertificateRequest.new(subject: { common_name: identifiers.first }, names: identifiers[1..-1])
31
- puts "=> Placing an order"
32
- order = acme.new_order(identifiers: identifiers, not_before: not_before, not_after: not_after)
33
-
34
- unless order.authorizations.empty? || order.status == 'ready'
35
- puts "=> Looking for required domain authorizations"
36
- puts
37
- order.authorizations.map(&:domain).each do |domain|
38
- puts " * #{domain}"
39
- end
40
- puts
41
-
42
- process_authorizations(order.authorizations)
43
- end
44
-
45
- cert = process_order_finalization(order, csr)
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
46
34
 
47
- puts "=> Certificate issued"
48
35
  puts
49
36
  print " * securing into the storage ..."
50
37
  storage.put_certificate(cert, certificate_key_passphrase)
@@ -175,111 +162,6 @@ module Acmesmith
175
162
 
176
163
  private
177
164
 
178
- def process_order_finalization(order, csr)
179
- puts "=> Finalizing the order"
180
- puts
181
-
182
- print " * Requesting..."
183
- order.finalize(csr: csr)
184
- puts" [ ok ]"
185
-
186
- while %w(ready processing).include?(order.status)
187
- order.reload()
188
- puts " * Waiting for procession: status=#{order.status}"
189
- sleep 2
190
- end
191
- puts
192
-
193
- Certificate.by_issuance(order.certificate, csr)
194
- end
195
-
196
- def process_authorizations(authzs)
197
- return if authzs.empty?
198
-
199
- targets = authzs.map do |authz|
200
- challenges = authz.challenges
201
- challenge = nil
202
- responder = config.challenge_responders.find do |x|
203
- challenge = challenges.find { |c|
204
- # OMG, acme-client might return a Hash instead of Acme::Client::Resources::Challenge::* object...
205
- challenge_type = c.is_a?(Hash) ? c[:challenge_type] : c.challenge_type
206
- x.support?(challenge_type)
207
- }
208
- end
209
- {domain: authz.domain, authz: authz, responder: responder, responder_id: responder.__id__, challenge: challenge}
210
- end
211
- target_by_responders = targets.group_by{ |_| _.fetch(:responder_id) }.map { |_, ts| [ts[0].fetch(:responder), ts] }
212
-
213
- begin
214
- target_by_responders.each do |responder, ts|
215
- puts "=> Responsing to the challenges for the following identifier:"
216
- puts
217
- puts " * Responder: #{responder.class}"
218
- puts " * Identifiers:"
219
- ts.each do |target|
220
- puts " - #{target.fetch(:domain)} (#{target.fetch(:challenge).challenge_type})"
221
- end
222
- puts
223
-
224
- responder.respond_all(*ts.map{ |t| [t.fetch(:domain), t.fetch(:challenge)] })
225
- end
226
-
227
- puts "=> Requesting validations..."
228
- puts
229
- targets.each do |target|
230
- print " * #{target[:domain]} (#{target[:challenge].challenge_type}) ..."
231
- target[:challenge].request_validation()
232
- puts " [ ok ]"
233
- end
234
- puts
235
-
236
- puts "=> Waiting for the validation..."
237
- puts
238
-
239
- loop do
240
- all_valid = true
241
- any_error = false
242
- targets.each do |target|
243
- next if target[:valid]
244
-
245
- target[:challenge].reload
246
- status = target[:challenge].status
247
-
248
- puts " * [#{target[:domain]}] status: #{status}"
249
-
250
- if status == 'valid'
251
- target[:valid] = true
252
- next
253
- end
254
-
255
- all_valid = false
256
- if status == 'invalid'
257
- any_error = true
258
- err = target[:challenge].error
259
- puts " ! [#{target[:domain]}] error: #{err.inspect}"
260
- end
261
- end
262
- break if all_valid || any_error
263
- sleep 3
264
- end
265
- puts
266
-
267
- target_by_responders.each do |responder, ts|
268
- puts "=> Cleaning the responses the challenges for the following identifier:"
269
- puts
270
- puts " * Responder: #{responder.class}"
271
- puts " * Identifiers:"
272
- ts.each do |target|
273
- puts " - #{target.fetch(:domain)} (#{target.fetch(:challenge).challenge_type})"
274
- end
275
- puts
276
-
277
- responder.cleanup_all(*ts.map{ |t| [t.fetch(:domain), t.fetch(:challenge)] })
278
- end
279
-
280
- puts "=> Authorized!"
281
- end
282
- end
283
165
 
284
166
  def config
285
167
  @config
@@ -296,7 +178,7 @@ module Acmesmith
296
178
  end
297
179
 
298
180
  def acme
299
- @acme ||= Acme::Client.new(private_key: account_key.private_key, directory: config.fetch('directory'))
181
+ @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)
300
182
  end
301
183
 
302
184
  def certificate_key_passphrase
@@ -1,10 +1,13 @@
1
1
  require 'yaml'
2
2
  require 'acmesmith/storages'
3
3
  require 'acmesmith/challenge_responders'
4
+ require 'acmesmith/challenge_responder_filter'
4
5
  require 'acmesmith/post_issuing_hooks'
5
6
 
6
7
  module Acmesmith
7
8
  class Config
9
+ ChallengeResponderRule = Struct.new(:challenge_responder, :filter, keyword_init: true)
10
+
8
11
  def self.load_yaml(path)
9
12
  new YAML.load_file(path)
10
13
  end
@@ -40,6 +43,18 @@ module Acmesmith
40
43
  @config.merge!(pair)
41
44
  end
42
45
 
46
+ def directory
47
+ @config.fetch('directory')
48
+ end
49
+
50
+ def connection_options
51
+ @config['connection_options'] || {}
52
+ end
53
+
54
+ def bad_nonce_retry
55
+ @config['bad_nonce_retry'] || 0
56
+ end
57
+
43
58
  def account_key_passphrase
44
59
  @config['account_key_passphrase']
45
60
  end
@@ -76,8 +91,14 @@ module Acmesmith
76
91
  @challenge_responders ||= begin
77
92
  specs = @config['challenge_responders'].kind_of?(Hash) ? @config['challenge_responders'].map { |k,v| [k => v] } : @config['challenge_responders']
78
93
  specs.flat_map do |specs_sub|
79
- specs_sub.map do |k, v|
80
- ChallengeResponders.find(k).new(**v.map{ |k_,v_| [k_.to_sym, v_]}.to_h)
94
+ specs_sub = specs_sub.dup
95
+ filter = (specs_sub.delete('filter') || {}).map { |k,v| [k.to_sym, v] }.to_h
96
+ specs_sub.map do |k,v|
97
+ responder = ChallengeResponders.find(k).new(**v.map{ |k_,v_| [k_.to_sym, v_]}.to_h)
98
+ ChallengeResponderRule.new(
99
+ challenge_responder: responder,
100
+ filter: ChallengeResponderFilter.new(responder, **filter),
101
+ )
81
102
  end
82
103
  end
83
104
  end