acmesmith 2.2.0 → 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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