acmesmith 0.11.1 → 2.0.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
- SHA256:
3
- metadata.gz: 9103307f7ec55de437d48f87b97234fde521c25378ccf0d874a4dafee084bfa4
4
- data.tar.gz: 3068795e54de705a900c98520c2568416ac38e63038313cee67ba43c3a6665ce
2
+ SHA1:
3
+ metadata.gz: b8046774804fb258740fcaaf2bbb19c823618082
4
+ data.tar.gz: 4b4c648818d71005f48d216aa12f63be9a292cfe
5
5
  SHA512:
6
- metadata.gz: 8993dbe4814bf78af2cd5ecb03ca392fbbd1c00a8a770c596a917de37b5dca1e39568fb7b243b7223503c1378d9673f41ca91158a440fcb89f3eced562a2c6ca
7
- data.tar.gz: aa3a6c64a357df063253b094f5ad85070932537e0baf8636f81d15530b080a2bd13885af4015e5e22d5f954a69f2d2143a49c631f2714bcc0ebdfd9dd03b86e8
6
+ metadata.gz: 8100bb4a174e42bce6e3a74d997c2cb59e40be2ebeb1c7dffb89b7e92ba408417dd57a2ed7f495d65e7ca24f027c4119deb38b627e0ecd337833c41946a57a4a
7
+ data.tar.gz: 589c69691b1e55f5e39af424232acedd918108aecb88e14e4e0d4f1c1fa6e85c6837ee0d36fd0821005de649a38f2d10322334d160d0832c963718bb06abe01a
data/CHANGELOG.md ADDED
@@ -0,0 +1,63 @@
1
+ ## v2.0.0 (2018-05-18)
2
+
3
+ ### Notable changes
4
+
5
+ - Support ACME v2
6
+ - Drop ACME v1 support
7
+ - Challenge responder
8
+ - New `dns-01` challenge responder `manual_dns` is bundled for manual DNS intervention.
9
+ - New API to allow challenge responders to respond many challenges at once, for efficiency
10
+ - Added its support to `route53` responder
11
+
12
+ #### Compatibility note
13
+
14
+ - `config['endpoint']` is removed. Use `config['directory']` to specify ACME v2 directory resource URL.
15
+ - The deprecated `config['post_issueing_hook']` is removed as planned.
16
+
17
+ ### CLI
18
+
19
+ #### Compatibility note
20
+
21
+ - Renamed several subcommands due to the changes in ACME (v2) semantics.
22
+
23
+ - `acmesmith register` -> `acmesmith new-account`
24
+ - `acmesmith request` -> `acmesmith order`
25
+
26
+ The previous names remain working, but are now marked as deprecated. These will be removed in the future release.
27
+
28
+ - Place warning for `acmesmith authorize` due to lack of implementation
29
+
30
+ (At this moment, LE doesn't provide new-authz API)
31
+
32
+ ### API and Internals
33
+
34
+ (Interface of `Client` class is still in beta. It's designed to be an external API, but interface are still subject to change)
35
+
36
+ #### Compatibility note
37
+
38
+ - `config['endpoint']` is removed. Use `config['directory']` to specify ACME v2 directory resource URL.
39
+ - Several renames due to the changes in ACME (v2) semantics.
40
+
41
+ - `Client#register` -> `new_account`
42
+ - `Client#request` -> `order`
43
+
44
+ - Place warning for `Client#authorize` due to lack of implementation
45
+
46
+ (At this moment, LE doesn't provide new-authz API)
47
+
48
+ - `Certificate#chain` now returns `Array<OpenSSL::X509::Certificate>`. Use `Certificate#chain_pems` to retrieve in `String`.
49
+
50
+ Note: Value for `:chain` key in a `Hash` returned by `Certificate#export` is kept `String` for Storages plugin compatibility.
51
+
52
+ #### New Features
53
+
54
+ - `ChallengeResponders::Base` now allows to respond many challenges at once.
55
+ - Added `#respond_all` and `#cleanup_all()` method to respond many challenges.
56
+ - Added `#cap_respond_all?` method to indicate a responder instance supports this capability or not.
57
+ - Base class now implements `respond`, `cleanup` for classes which implement only the new `*_all` method.
58
+
59
+
60
+
61
+ ## Prior versions
62
+
63
+ See https://github.com/sorah/acmesmith/releases
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # Acmesmith: A simple, effective ACME client to use with many servers and a cloud
1
+ # Acmesmith: A simple, effective ACME v2 client to use with many servers and a cloud
2
2
 
3
3
  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).
4
4
 
@@ -6,20 +6,15 @@ This tool is written in Ruby, but Acmesmith saves certificates in simple scheme,
6
6
 
7
7
  ## Features
8
8
 
9
- - ACME client designed to work on multiple servers
9
+ - ACME v2 client designed to work on multiple servers
10
10
  - ACME registration, domain authorization, certificate requests
11
11
  - Tested against [Let's encrypt](https://letsencrypt.org)
12
12
  - Storing keys in several ways
13
13
  - Challenge response
14
14
  - Many cloud services support
15
- - AWS S3 storage and Route53 `dns-01` responder support out-of-the-box
15
+ - AWS S3 storage and Route 53 `dns-01` responder support out-of-the-box
16
16
  - 3rd party plugins available for OpenStack designate, Google Cloud DNS, simple http-01, and Google Cloud Storage. See [Plugins](#3rd-party-plugins) below
17
17
 
18
- ### Planned
19
-
20
- - Automated deployments support (post issurance hook)
21
- - Example shellscripts to fetch certificates
22
-
23
18
  ## Installation
24
19
 
25
20
  Add this line to your application's Gemfile:
@@ -39,12 +34,11 @@ Or install it yourself as:
39
34
  ## Usage
40
35
 
41
36
  ```
42
- $ acmesmith register CONTACT # Create account key (contact e.g. mailto:xxx@example.org)
37
+ $ acmesmith new-account CONTACT # Create account key (contact e.g. mailto:xxx@example.org)
43
38
  ```
44
39
 
45
40
  ```
46
- $ acmesmith authorize DOMAIN # Get authz for DOMAIN.
47
- $ acmesmith request COMMON_NAME [SAN] # request certificate for CN +COMMON_NAME+ with SANs +SAN+
41
+ $ acmesmith order COMMON_NAME [SAN] # request certificate for CN +COMMON_NAME+ with SANs +SAN+
48
42
  $ acmesmith add-san COMMON_NAME [SAN] # re-request existing certificate of CN with additional SAN(s)
49
43
  ```
50
44
 
@@ -77,8 +71,8 @@ See `acmesmith help [subcommand]` for more help.
77
71
  See [config.sample.yml](./config.sample.yml) to start. Default configuration file is `./acmesmith.yml`.
78
72
 
79
73
  ``` yaml
80
- endpoint: https://acme-staging.api.letsencrypt.org/
81
- # endpoint: https://acme-v01.api.letsencrypt.org/ # productilon
74
+ directory: https://acme-staging-v02.api.letsencrypt.org/
75
+ # directory: https://acme-v02.api.letsencrypt.org/ # productilon
82
76
 
83
77
  storage:
84
78
  # configure where to store keys and certificates; described later
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", '~> 1'
24
+ spec.add_dependency "acme-client", '~> 2'
25
25
  spec.add_dependency "aws-sdk-acm"
26
26
  spec.add_dependency "aws-sdk-route53"
27
27
  spec.add_dependency "aws-sdk-s3"
data/config.sample.yml CHANGED
@@ -1,5 +1,6 @@
1
- endpoint: https://acme-staging.api.letsencrypt.org/
2
- # endpoint: https://acme-v01.api.letsencrypt.org/
1
+ directory: https://acme-staging-v02.api.letsencrypt.org/directory
2
+ # directory: https://acme-v02.api.letsencrypt.org/directory
3
+
3
4
  storage:
4
5
  type: s3
5
6
  region: 'ap-northeast-1'
@@ -4,8 +4,13 @@ module Acmesmith
4
4
  class Certificate
5
5
  class PassphraseRequired < StandardError; end
6
6
 
7
- def self.from_acme_client_certificate(c)
8
- new c.x509, c.chain_to_pem, c.request.private_key, nil, c.request.csr
7
+ def self.split_pems(pems)
8
+ pems.each_line.slice_before(/^-----BEGIN CERTIFICATE-----$/).map(&:join)
9
+ end
10
+
11
+ def self.by_issuance(pem_chain, csr)
12
+ pems = split_pems(pem_chain)
13
+ new(*pems, csr.private_key, nil, csr)
9
14
  end
10
15
 
11
16
  def initialize(certificate, chain, private_key, key_passphrase = nil, csr = nil)
@@ -17,14 +22,27 @@ module Acmesmith
17
22
  else
18
23
  raise TypeError, 'certificate is expected to be a String or OpenSSL::X509::Certificate'
19
24
  end
20
- @chain = case chain
21
- when String
22
- chain
23
- when nil
24
- chain
25
- else
26
- raise TypeError, 'chain is expected to be a String'
27
- end
25
+ chain = case chain
26
+ when String
27
+ self.class.split_pems(chain)
28
+ when Array
29
+ chain
30
+ when nil
31
+ []
32
+ else
33
+ raise TypeError, 'chain is expected to be an Array<String or OpenSSL::X509::Certificate> or nil'
34
+ end
35
+
36
+ @chain = chain.map { |cert|
37
+ case cert
38
+ when OpenSSL::X509::Certificate
39
+ cert
40
+ when String
41
+ OpenSSL::X509::Certificate.new(cert)
42
+ else
43
+ raise TypeError, 'chain is expected to be an Array<String or OpenSSL::X509::Certificate> or nil'
44
+ end
45
+ }
28
46
 
29
47
  case private_key
30
48
  when String
@@ -71,7 +89,11 @@ module Acmesmith
71
89
  end
72
90
 
73
91
  def fullchain
74
- "#{certificate.to_pem}\n#{chain}".gsub(/\n+/,?\n)
92
+ "#{certificate.to_pem}\n#{issuer_pems}".gsub(/\n+/,?\n)
93
+ end
94
+
95
+ def issuer_pems
96
+ chain.map(&:to_pem).join("\n")
75
97
  end
76
98
 
77
99
  def common_name
@@ -91,8 +113,9 @@ module Acmesmith
91
113
  def export(passphrase, cipher: OpenSSL::Cipher.new('aes-256-cbc'))
92
114
  {}.tap do |h|
93
115
  h[:certificate] = certificate.to_pem
94
- h[:chain] = chain
116
+ h[:chain] = issuer_pems
95
117
  h[:fullchain] = fullchain
118
+
96
119
  h[:private_key] = if passphrase
97
120
  private_key.export(cipher, passphrase)
98
121
  else
@@ -1,19 +1,57 @@
1
1
  module Acmesmith
2
2
  module ChallengeResponders
3
3
  class Base
4
+ # Return supported challenge types
4
5
  def support?(type)
5
6
  raise NotImplementedError
6
7
  end
7
8
 
9
+ # Return 'true' if implements respond_all method.
10
+ def cap_respond_all?
11
+ false
12
+ end
13
+
8
14
  def initialize()
9
15
  end
10
16
 
11
- def respond(challenge)
12
- raise NotImplementedError
17
+ # Respond to the given challenges (1 or more).
18
+ def respond_all(*domain_and_challenges)
19
+ if cap_respond_all?
20
+ raise NotImplementedError
21
+ else
22
+ domain_and_challenges.each do |dc|
23
+ respond(*dc)
24
+ end
25
+ end
13
26
  end
14
27
 
15
- def cleanup(challenge)
16
- raise NotImplementedError
28
+ # Clean up responses for the given challenges (1 or more).
29
+ def cleanup_all(*domain_and_challenges)
30
+ if cap_respond_all?
31
+ raise NotImplementedError
32
+ else
33
+ domain_and_challenges.each do |dc|
34
+ cleanup(*dc)
35
+ end
36
+ end
37
+ end
38
+
39
+ # If cap_respond_all? is true, you don't need to implement this method.
40
+ def respond(domain, challenge)
41
+ if cap_respond_all?
42
+ respond_all([domain, challenge])
43
+ else
44
+ raise NotImplementedError
45
+ end
46
+ end
47
+
48
+ # If cap_respond_all? is true, you don't need to implement this method.
49
+ def cleanup(domain, challenge)
50
+ if cap_respond_all?
51
+ cleanup_all([domain, challenge])
52
+ else
53
+ raise NotImplementedError
54
+ end
17
55
  end
18
56
  end
19
57
  end
@@ -0,0 +1,45 @@
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
@@ -13,6 +13,10 @@ module Acmesmith
13
13
  type == 'dns-01'
14
14
  end
15
15
 
16
+ def cap_respond_all?
17
+ true
18
+ end
19
+
16
20
  def initialize(aws_access_key: nil, hosted_zone_map: {})
17
21
  @route53 = Aws::Route53::Client.new({region: 'us-east-1'}.tap do |opt|
18
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
@@ -21,84 +25,102 @@ module Acmesmith
21
25
  @hosted_zone_cache = {}
22
26
  end
23
27
 
24
- def respond(domain, challenge)
25
- puts "=> Responding challenge dns-01 for #{domain} in #{self.class.name}"
28
+ def respond_all(*domain_and_challenges)
29
+ challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) }
26
30
 
27
- domain = canonical_fqdn(domain)
28
- record_name = "#{challenge.record_name}.#{domain}"
29
- record_type = challenge.record_type
30
- record_content = "\"#{challenge.record_content}\""
31
- zone_id = find_hosted_zone(domain)
32
-
33
- puts " * UPSERT: #{record_type} #{record_name.inspect}, #{record_content.inspect} on #{zone_id}"
34
- change_resp = @route53.change_resource_record_sets(
35
- hosted_zone_id: zone_id, # required
36
- change_batch: { # required
37
- comment: "ACME challenge response",
38
- changes: [
39
- {
40
- action: "UPSERT",
41
- resource_record_set: { # required
42
- name: record_name, # required
43
- type: record_type,
44
- ttl: 5,
45
- resource_records: [
46
- value: record_content
47
- ],
48
- },
49
- },
50
- ],
51
- },
52
- )
53
-
54
- change_id = change_resp.change_info.id
55
- puts " * requested change: #{change_id}"
56
-
57
- puts "=> Waiting for change"
58
- while (resp = @route53.get_change(id: change_id)).change_info.status != 'INSYNC'
59
- puts " * change #{change_id.inspect} is still #{resp.change_info.status.inspect} ..."
60
- sleep 5
31
+ zone_and_batches = challenges_by_hosted_zone.map do |zone_id, dcs|
32
+ [zone_id, change_batch_for_challenges(dcs, action: 'UPSERT')]
61
33
  end
62
34
 
63
- puts " * synced!"
35
+ change_ids = request_changing_rrset(zone_and_batches, comment: 'for challenge response')
36
+ wait_for_sync(change_ids)
64
37
  end
65
38
 
66
- def cleanup(domain, challenge)
67
- puts "=> Cleaning up challenge dns-01 for #{domain} in #{self.class.name}"
39
+ def cleanup_all(*domain_and_challenges)
40
+ challenges_by_hosted_zone = domain_and_challenges.group_by { |(domain, _)| find_hosted_zone(domain) }
68
41
 
69
- domain = canonical_fqdn(domain)
70
- record_name = "#{challenge.record_name}.#{domain}"
71
- record_type = challenge.record_type
72
- record_content = "\"#{challenge.record_content}\""
73
- zone_id = find_hosted_zone(domain)
74
-
75
- puts " * DELETE: #{record_type} #{record_name.inspect}, #{record_content.inspect} on #{zone_id}"
76
- change_resp = @route53.change_resource_record_sets(
77
- hosted_zone_id: zone_id, # required
78
- change_batch: { # required
79
- comment: "ACME challenge response: cleanup",
80
- changes: [
81
- {
82
- action: "DELETE", # required, accepts CREATE, DELETE, UPSERT
83
- resource_record_set: { # required
84
- name: record_name, # required
85
- type: record_type,
86
- ttl: 5,
87
- resource_records: [
88
- value: record_content
89
- ],
90
- },
91
- },
92
- ],
93
- },
94
- )
95
-
96
- change_id = change_resp.change_info.id
97
- puts " * requested: #{change_id}"
42
+ zone_and_batches = challenges_by_hosted_zone.map do |zone_id, dcs|
43
+ [zone_id, change_batch_for_challenges(dcs, action: 'DELETE', comment: '(cleanup)')]
44
+ end
45
+
46
+ request_changing_rrset(zone_and_batches, comment: 'to remove challenge responses')
98
47
  end
99
48
 
100
49
  private
101
50
 
51
+ def request_changing_rrset(zone_and_batches, comment: nil)
52
+ puts "=> Requesting RRSet change #{comment}"
53
+ puts
54
+ change_ids = zone_and_batches.map do |(zone_id, change_batch)|
55
+ puts " * #{zone_id}:"
56
+ change_batch.fetch(:changes).each do |b|
57
+ rrset = b.fetch(:resource_record_set)
58
+ puts " - #{b.fetch(:action)}: #{rrset.fetch(:name)} #{rrset.fetch(:ttl)} #{rrset.fetch(:type)} #{rrset.dig(:resource_records, 0, :value)}"
59
+ end
60
+ print " ... "
61
+
62
+ resp = @route53.change_resource_record_sets(
63
+ hosted_zone_id: zone_id, # required
64
+ change_batch: change_batch,
65
+ )
66
+ change_id = resp.change_info.id
67
+
68
+ puts "[ ok ] #{change_id}"
69
+ puts
70
+ change_id
71
+ end
72
+
73
+ change_ids
74
+ end
75
+
76
+
77
+ def wait_for_sync(change_ids)
78
+ puts "=> Waiting for change to be in sync"
79
+ puts
80
+
81
+ all_sync = false
82
+ until all_sync
83
+ sleep 4
84
+
85
+ all_sync = true
86
+ change_ids.each do |id|
87
+ change = @route53.get_change(id: id)
88
+
89
+ sync = change.change_info.status == 'INSYNC'
90
+ all_sync = false unless sync
91
+
92
+ puts " * #{id}: #{change.change_info.status}"
93
+ sleep 0.2
94
+ end
95
+ end
96
+ puts
97
+
98
+ end
99
+
100
+ def change_batch_for_challenges(domain_and_challenges, comment: nil, action: 'UPSERT')
101
+ {
102
+ comment: "ACME challenge response #{comment}",
103
+ changes: domain_and_challenges.map do |d,c|
104
+ {
105
+ action: action,
106
+ resource_record_set: rrset_for_challenge(d,c),
107
+ }
108
+ end,
109
+ }
110
+ end
111
+
112
+ def rrset_for_challenge(domain, challenge)
113
+ domain = canonical_fqdn(domain)
114
+ {
115
+ name: "#{challenge.record_name}.#{domain}",
116
+ type: challenge.record_type,
117
+ ttl: 5,
118
+ resource_records: [
119
+ value: "\"#{challenge.record_content}\"",
120
+ ],
121
+ }
122
+ end
123
+
102
124
  def canonical_fqdn(domain)
103
125
  "#{domain}.".sub(/\.+$/, '')
104
126
  end
@@ -1,7 +1,7 @@
1
1
  require 'acmesmith/account_key'
2
- require 'acmesmith/acme_client'
3
2
  require 'acmesmith/certificate'
4
3
  require 'acmesmith/save_certificate_service'
4
+ require 'acme-client'
5
5
 
6
6
  module Acmesmith
7
7
  class Client
@@ -9,100 +9,57 @@ module Acmesmith
9
9
  @config ||= config
10
10
  end
11
11
 
12
- def register(contact)
12
+ def new_account(contact, tos_agreed: true)
13
13
  key = AccountKey.generate
14
- acme = AcmeClient.new(key, config['endpoint'])
15
- registration = acme.register(contact)
16
- registration.agree_terms
14
+ acme = Acme::Client.new(private_key: key.private_key, directory: config.fetch('directory'))
15
+ client = acme.new_account(contact: contact, terms_of_service_agreed: tos_agreed)
17
16
 
18
17
  storage.put_account_key(key, account_key_passphrase)
19
18
 
20
19
  key
21
20
  end
22
21
 
23
- def authorize(*domains)
24
- targets = domains.map do |domain|
25
- authz = acme.authorize(domain)
26
- challenges = [authz.http01, authz.dns01, authz.tls_sni01].compact
27
- challenge = nil
28
- responder = config.challenge_responders.find do |x|
29
- challenge = challenges.find { |_| x.support?(_.class::CHALLENGE_TYPE) }
30
- end
31
- {domain: domain, authz: authz, responder: responder, challenge: challenge}
22
+ 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}"
32
27
  end
33
-
34
- begin
35
- targets.each do |target|
36
- target[:responder].respond(target[:domain], target[:challenge])
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}"
37
39
  end
40
+ puts
38
41
 
39
- targets.each do |target|
40
- puts "=> Requesting verifications..."
41
- acme.request_verification(target[:challenge])
42
- end
43
- loop do
44
- all_valid = true
45
- targets.each do |target|
46
- next if target[:valid]
47
-
48
- status = acme.verify_status(target[:challenge])
49
- puts " * [#{target[:domain]}] verify_status: #{status}"
50
-
51
- if status == 'valid'
52
- target[:valid] = true
53
- next
54
- end
55
-
56
- all_valid = false
57
- if status == "invalid"
58
- err = target[:challenge].error
59
- puts " ! [#{target[:domain]}] #{err["type"]}: #{err["detail"]}"
60
- end
61
- end
62
- break if all_valid
63
- sleep 3
64
- end
65
- puts "=> Done"
66
- ensure
67
- targets.each do |target|
68
- target[:responder].cleanup(target[:domain], target[:challenge])
69
- end
42
+ process_authorizations(order.authorizations)
70
43
  end
71
- end
72
44
 
73
- def request(common_name, *sans)
74
- csr = Acme::Client::CertificateRequest.new(common_name: common_name, names: sans)
75
- retried = false
76
- acme_cert = begin
77
- acme.new_certificate(csr)
78
- rescue Acme::Client::Error::Unauthorized => e
79
- raise unless config.auto_authorize_on_request
80
- raise if retried
81
-
82
- puts "=> Authorizing unauthorized domain names"
83
- # https://github.com/letsencrypt/boulder/blob/b9369a481415b3fe31e010b34e2ff570b89e42aa/ra/ra.go#L604
84
- m = e.message.match(/authorizations for these names not found or expired: ((?:[a-zA-Z0-9_.\-]+(?:,\s+|$))+)/)
85
- if m && m[1]
86
- domains = m[1].split(/,\s+/)
87
- else
88
- warn " ! Error message on certificate request was #{e.message.inspect} and acmesmith couldn't determine which domain names are unauthorized (maybe a bug)"
89
- warn " ! Attempting to authorize all domains in this certificate reuqest for now."
90
- domains = [common_name, *sans]
91
- end
92
- puts " * #{domains.join(', ')}"
93
- authorize(*domains)
94
- retried = true
95
- retry
96
- end
45
+ cert = process_order_finalization(order, csr)
97
46
 
98
- cert = Certificate.from_acme_client_certificate(acme_cert)
47
+ puts "=> Certificate issued"
48
+ puts
49
+ print " * securing into the storage ..."
99
50
  storage.put_certificate(cert, certificate_key_passphrase)
51
+ puts " [ ok ]"
52
+ puts
100
53
 
101
54
  execute_post_issue_hooks(cert)
102
55
 
103
56
  cert
104
57
  end
105
58
 
59
+ def authorize(*identifiers)
60
+ raise NotImplementedError, "Domain authorization in advance is still not available in acme-client (v2). Required authorizations will be performed when ordering certificates"
61
+ end
62
+
106
63
  def post_issue_hooks(common_name)
107
64
  cert = storage.get_certificate(common_name)
108
65
  execute_post_issue_hooks(cert)
@@ -110,9 +67,12 @@ module Acmesmith
110
67
 
111
68
  def execute_post_issue_hooks(certificate)
112
69
  hooks = config.post_issuing_hooks(certificate.common_name)
70
+ return if hooks.empty?
71
+ puts "=> Executing post issuing hooks for CN=#{certificate.common_name}"
113
72
  hooks.each do |hook|
114
73
  hook.run(certificate: certificate)
115
74
  end
75
+ puts
116
76
  end
117
77
 
118
78
  def certificate_versions(common_name)
@@ -201,7 +161,7 @@ module Acmesmith
201
161
  puts " Not valid after: #{not_after}"
202
162
  next unless (cert.certificate.not_after.utc - Time.now.utc) < (days.to_i * 86400)
203
163
  puts " * Renewing: CN=#{cert.common_name}, SANs=#{cert.sans.join(',')}"
204
- request(cert.common_name, *cert.sans)
164
+ order(cert.common_name, *cert.sans)
205
165
  end
206
166
  end
207
167
 
@@ -210,11 +170,113 @@ module Acmesmith
210
170
  cert = storage.get_certificate(common_name)
211
171
  sans = cert.sans + add_sans
212
172
  puts " * SANs will be: #{sans.join(?,)}"
213
- request(cert.common_name, *sans)
173
+ order(cert.common_name, *sans)
214
174
  end
215
175
 
216
176
  private
217
177
 
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 { |_| x.support?(_.challenge_type) }
204
+ end
205
+ {domain: authz.domain, authz: authz, responder: responder, responder_id: responder.__id__, challenge: challenge}
206
+ end
207
+ target_by_responders = targets.group_by{ |_| _.fetch(:responder_id) }.map { |_, ts| [ts[0].fetch(:responder), ts] }
208
+
209
+ begin
210
+ target_by_responders.each do |responder, ts|
211
+ puts "=> Responsing to the challenges for the following identifier:"
212
+ puts
213
+ puts " * Responder: #{responder.class}"
214
+ puts " * Identifiers:"
215
+ ts.each do |target|
216
+ puts " - #{target.fetch(:domain)} (#{target.fetch(:challenge).challenge_type})"
217
+ end
218
+ puts
219
+
220
+ responder.respond_all(*ts.map{ |t| [t.fetch(:domain), t.fetch(:challenge)] })
221
+ end
222
+
223
+ puts "=> Requesting validations..."
224
+ puts
225
+ targets.each do |target|
226
+ print " * #{target[:domain]} (#{target[:challenge].challenge_type}) ..."
227
+ target[:challenge].request_validation()
228
+ puts " [ ok ]"
229
+ end
230
+ puts
231
+
232
+ puts "=> Waiting for the validation..."
233
+ puts
234
+
235
+ loop do
236
+ all_valid = true
237
+ any_error = false
238
+ targets.each do |target|
239
+ next if target[:valid]
240
+
241
+ target[:challenge].reload
242
+ status = target[:challenge].status
243
+
244
+ puts " * [#{target[:domain]}] status: #{status}"
245
+
246
+ if status == 'valid'
247
+ target[:valid] = true
248
+ next
249
+ end
250
+
251
+ all_valid = false
252
+ if status == 'invalid'
253
+ any_error = true
254
+ err = target[:challenge].error
255
+ puts " ! [#{target[:domain]}] error: #{err.inspect}"
256
+ end
257
+ end
258
+ break if all_valid || any_error
259
+ sleep 3
260
+ end
261
+ puts
262
+
263
+ target_by_responders.each do |responder, ts|
264
+ puts "=> Cleaning the responses the challenges for the following identifier:"
265
+ puts
266
+ puts " * Responder: #{responder.class}"
267
+ puts " * Identifiers:"
268
+ ts.each do |target|
269
+ puts " - #{target.fetch(:domain)} (#{target.fetch(:challenge).challenge_type})"
270
+ end
271
+ puts
272
+
273
+ responder.cleanup_all(*ts.map{ |t| [t.fetch(:domain), t.fetch(:challenge)] })
274
+ end
275
+
276
+ puts "=> Authorized!"
277
+ end
278
+ end
279
+
218
280
  def config
219
281
  @config
220
282
  end
@@ -230,7 +292,7 @@ module Acmesmith
230
292
  end
231
293
 
232
294
  def acme
233
- @acme ||= AcmeClient.new(account_key, config['endpoint'])
295
+ @acme ||= Acme::Client.new(private_key: account_key.private_key, directory: config.fetch('directory'))
234
296
  end
235
297
 
236
298
  def certificate_key_passphrase
@@ -8,22 +8,36 @@ module Acmesmith
8
8
  class_option :config, default: './acmesmith.yml', aliases: %w(-c)
9
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
10
 
11
- desc "register CONTACT", "Create account key (contact e.g. mailto:xxx@example.org)"
12
- def register(contact)
13
- key = client.register(contact)
14
- puts "Generated:\n#{key.private_key.public_key.to_pem}"
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}"
15
17
  end
16
18
 
17
- desc "authorize DOMAIN [DOMAIN ...]", "Get authz for DOMAIN."
19
+ desc "authorize DOMAIN [DOMAIN ...]", "(Implementation disabled for v2) Get authz for DOMAIN."
18
20
  def authorize(*domains)
19
- client.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)
20
31
  end
21
32
 
22
- desc "request COMMON_NAME [SAN]", "request certificate for CN +COMMON_NAME+ with SANs +SAN+"
23
- def request(common_name, *sans)
24
- cert = client.request(common_name, *sans)
25
- puts cert.certificate.to_text
26
- puts cert.certificate.to_pem
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
+ def order(common_name, *sans)
36
+ cert = client.order(common_name, *sans)
37
+ if options[:show_certificate]
38
+ puts cert.certificate.to_text
39
+ puts cert.certificate.to_pem
40
+ end
27
41
  end
28
42
 
29
43
  desc "post-issue-hooks COMMON_NAME", "Run all post-issuing hooks for common name. (for testing purpose)"
@@ -131,6 +145,29 @@ module Acmesmith
131
145
  client.add_san(common_name, *add_sans)
132
146
  end
133
147
 
148
+ desc "register CONTACT", "(deprecated, use 'acmesmith new-account')"
149
+ def register(contact)
150
+ warn "!"
151
+ warn "! DEPRECATION WARNING: Use 'acmesmith new-account' command"
152
+ warn "! There is no user-facing breaking changes. It takes the same arguments with 'acmesmith register'."
153
+ warn "!"
154
+ warn "! This is due to change in semantics of ACME v2. ACME v2 defines 'new-account' instead of 'register' in v1."
155
+ warn "!"
156
+ new_account(contact)
157
+ end
158
+
159
+ desc "request COMMON_NAME [SAN]", "(deprecated, use 'acmesmith order')"
160
+ method_option :show_certificate, type: :boolean, aliases: %w(-s), default: true, desc: 'show an issued certificate in PEM and text when exiting'
161
+ def request(common_name, *sans)
162
+ warn "!"
163
+ warn "! DEPRECATION WARNING: Use 'acmesmith order' command"
164
+ warn "! There is no user-facing breaking changes. It takes the same arguments with 'acmesmith request'."
165
+ warn "!"
166
+ warn "! This is due to change in semantics of ACME v2. ACME v2 defines 'order' instead of 'request' in v1."
167
+ warn "!"
168
+ order(common_name, *sans)
169
+ end
170
+
134
171
  private
135
172
 
136
173
  def client
@@ -19,13 +19,12 @@ module Acmesmith
19
19
  raise ArgumentError, "config['storage'] must be provided"
20
20
  end
21
21
 
22
- unless @config['endpoint']
23
- raise ArgumentError, "config['endpoint'] must be provided, e.g. https://acme-v01.api.letsencrypt.org/ or https://acme-staging.api.letsencrypt.org/"
22
+ if @config['endpoint'] and !@config['directory']
23
+ 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']."
24
24
  end
25
25
 
26
- if @config['post_issueing_hooks']
27
- warn '!! Deprecation warning: configuration "post_issueing_hooks" is now "post_issuing_hooks" (what a typo!). It will not work in the future release.'
28
- @config['post_issuing_hooks'] = @config.delete('post_issueing_hooks')
26
+ unless @config['directory']
27
+ 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"
29
28
  end
30
29
  end
31
30
 
@@ -33,6 +32,10 @@ module Acmesmith
33
32
  @config[key]
34
33
  end
35
34
 
35
+ def fetch(*args)
36
+ @config.fetch(*args)
37
+ end
38
+
36
39
  def merge!(pair)
37
40
  @config.merge!(pair)
38
41
  end
@@ -1,26 +1,9 @@
1
1
  require 'acmesmith/utils/finder'
2
2
 
3
3
  module Acmesmith
4
- module PostIssueingHooks
5
- def self.find(name)
6
- warn "!! DEPRECATION WARNING: PostIssueingHooks.find is deprecated, use PostIssuingHooks.find (#{caller[0]})"
7
- return Utils::Finder.find(self, 'acmesmith/post_issueing_hooks', name)
8
- end
9
- end
10
-
11
4
  module PostIssuingHooks
12
5
  def self.find(name)
13
- begin
14
- return Utils::Finder.find(self, 'acmesmith/post_issuing_hooks', name)
15
- rescue Utils::Finder::NotFound => e
16
- begin
17
- klass = Utils::Finder.find(PostIssueingHooks, 'acmesmith/post_issueing_hooks', name)
18
- warn "!! DEPRECATION WARNING (#{klass}): Placing in acmesmith/post_issueing_hooks/... is deprecated. Move to acmesmith/post_issuing_hooks/..."
19
- return klass
20
- rescue Utils::Finder::NotFound
21
- raise e
22
- end
23
- end
6
+ Utils::Finder.find(self, 'acmesmith/post_issuing_hooks', name)
24
7
  end
25
8
  end
26
9
  end
@@ -1,3 +1,3 @@
1
1
  module Acmesmith
2
- VERSION = "0.11.1"
2
+ VERSION = "2.0.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: acmesmith
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.1
4
+ version: 2.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - sorah (Shota Fukumori)
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-05-15 00:00:00.000000000 Z
11
+ date: 2018-05-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: acme-client
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1'
19
+ version: '2'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1'
26
+ version: '2'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: aws-sdk-acm
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -138,6 +138,7 @@ files:
138
138
  - ".gitignore"
139
139
  - ".rspec"
140
140
  - ".travis.yml"
141
+ - CHANGELOG.md
141
142
  - Gemfile
142
143
  - LICENSE.txt
143
144
  - README.md
@@ -148,10 +149,10 @@ files:
148
149
  - docs/vendor/aws.md
149
150
  - lib/acmesmith.rb
150
151
  - lib/acmesmith/account_key.rb
151
- - lib/acmesmith/acme_client.rb
152
152
  - lib/acmesmith/certificate.rb
153
153
  - lib/acmesmith/challenge_responders.rb
154
154
  - lib/acmesmith/challenge_responders/base.rb
155
+ - lib/acmesmith/challenge_responders/manual_dns.rb
155
156
  - lib/acmesmith/challenge_responders/route53.rb
156
157
  - lib/acmesmith/client.rb
157
158
  - lib/acmesmith/command.rb
@@ -191,7 +192,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
191
192
  version: '0'
192
193
  requirements: []
193
194
  rubyforge_project:
194
- rubygems_version: 2.7.6
195
+ rubygems_version: 2.6.8
195
196
  signing_key:
196
197
  specification_version: 4
197
198
  summary: ACME client (Let's encrypt client) to manage certificate in multi server
@@ -1,64 +0,0 @@
1
- require 'acme-client'
2
-
3
- module Acmesmith
4
- class AcmeClient
5
- # @param account_key [Acmesmith::AccountKey]
6
- # @param endpoint [String]
7
- def initialize(account_key, endpoint)
8
- @acme = Acme::Client.new(private_key: account_key.private_key, endpoint: endpoint)
9
- end
10
-
11
- # @param contact [String]
12
- def register(contact)
13
- retry_once_on_bad_nonce do
14
- @acme.register(contact: contact)
15
- end
16
- end
17
-
18
- # @param domain [String]
19
- def authorize(domain)
20
- retry_once_on_bad_nonce do
21
- @acme.authorize(domain: domain)
22
- end
23
- end
24
-
25
- # @param csr [Acme::Client::CertificateRequest]
26
- def new_certificate(csr)
27
- retry_once_on_bad_nonce do
28
- @acme.new_certificate(csr)
29
- end
30
- end
31
-
32
- # @param challenge [Acme::Client::Resources::Challenges::Base]
33
- def request_verification(challenge)
34
- retry_once_on_bad_nonce do
35
- challenge.request_verification
36
- end
37
- end
38
-
39
- # @param challenge [Acme::Client::Resources::Challenges::Base]
40
- def verify_status(challenge)
41
- retry_once_on_bad_nonce do
42
- challenge.verify_status
43
- end
44
- end
45
-
46
- private
47
-
48
- def retry_once_on_bad_nonce(&block)
49
- retried = false
50
- begin
51
- block.call
52
- rescue Acme::Client::Error::BadNonce => e
53
- # Let's Encrypt returns badNonce error when the client sends too-old
54
- # nonce. So retry the request once.
55
- if retried
56
- raise e
57
- else
58
- retried = true
59
- retry
60
- end
61
- end
62
- end
63
- end
64
- end