anchor-pki 0.5.0 → 0.6.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c07e54ede7ccff887e6023fe9842989720fdf9d63f159e4a5a3b4ba1a92c6c1a
4
- data.tar.gz: dc0ba249f2125b9cc9d6a4a2dfda9fb042e86c0c7691d45fd6f7b432c5aa2d43
3
+ metadata.gz: f6f3aa56dc5a365db7f3c2b4f255ac538ba455fad1bdf758f19450bca287381e
4
+ data.tar.gz: 6bf38bec006856c4a60246e3b53a119296e9c4557a17f3c4d0f5ef50404102ca
5
5
  SHA512:
6
- metadata.gz: e074e115e1035f8ed0b2289859073516b9cad04f9452f5dd8aa8dc070b8cdd119db2abf26b5bf2540a410818948fd4dfff4ca854cdc579b457bf15ae4065428a
7
- data.tar.gz: 916f262c31580a3f92ca909bd0900d979a9ccb7a93d3f2cdb9e538bc9b29e8215db96468b5fe21c49c16ed836260857cc392f697772f032e6f76c28540af0653
6
+ metadata.gz: 5d24d34f6c5448e2ba33ac004e6d2e99a7ff0c3b31840c119f5d0256576d6146e6e13b120251db58ab9d80db4f4927f1c71f2c38397ca434a66ded8124c300e8
7
+ data.tar.gz: 30ba030f48985e35f6d63e1ac02943bf0bed301eba7a36cc6728caa5dbee0395e14d8a4ba3e2d43edf68fca461ee5cb60ae204542b6f4f30c1ffa3655cf68986
data/Gemfile.lock CHANGED
@@ -1,22 +1,26 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- anchor-pki (0.2.1)
4
+ anchor-pki (0.6.1)
5
5
  acme-client (~> 2.0.13)
6
+ pstore (~> 0.1)
6
7
 
7
8
  GEM
8
9
  remote: https://rubygems.org/
9
10
  specs:
10
- acme-client (2.0.13)
11
+ acme-client (2.0.15)
11
12
  faraday (>= 1.0, < 3.0.0)
12
13
  faraday-retry (>= 1.0, < 3.0.0)
13
14
  addressable (2.8.4)
14
15
  public_suffix (>= 2.0.2, < 6.0)
15
16
  ast (2.4.2)
17
+ base64 (0.2.0)
16
18
  crack (0.4.5)
17
19
  rexml
18
20
  diff-lcs (1.5.0)
19
- faraday (2.7.5)
21
+ docile (1.4.0)
22
+ faraday (2.7.12)
23
+ base64
20
24
  faraday-net_http (>= 2.0, < 3.1)
21
25
  ruby2_keywords (>= 0.0.4)
22
26
  faraday-net_http (3.0.2)
@@ -28,9 +32,10 @@ GEM
28
32
  parallel (1.23.0)
29
33
  parser (3.2.2.1)
30
34
  ast (~> 2.4.1)
35
+ pstore (0.1.3)
31
36
  public_suffix (5.0.1)
32
37
  rainbow (3.1.1)
33
- rake (13.0.6)
38
+ rake (13.1.0)
34
39
  regexp_parser (2.8.0)
35
40
  rexml (3.2.5)
36
41
  rspec (3.12.0)
@@ -68,6 +73,12 @@ GEM
68
73
  rubocop-factory_bot (~> 2.22)
69
74
  ruby-progressbar (1.13.0)
70
75
  ruby2_keywords (0.0.5)
76
+ simplecov (0.22.0)
77
+ docile (~> 1.1)
78
+ simplecov-html (~> 0.11)
79
+ simplecov_json_formatter (~> 0.1)
80
+ simplecov-html (0.12.3)
81
+ simplecov_json_formatter (0.1.4)
71
82
  unicode-display_width (2.4.2)
72
83
  vcr (6.1.0)
73
84
  webmock (3.18.1)
@@ -78,7 +89,9 @@ GEM
78
89
  PLATFORMS
79
90
  aarch64-linux
80
91
  arm64-darwin-21
92
+ arm64-darwin-23
81
93
  x86_64-darwin-22
94
+ x86_64-linux
82
95
 
83
96
  DEPENDENCIES
84
97
  anchor-pki!
@@ -87,6 +100,7 @@ DEPENDENCIES
87
100
  rspec (~> 3.9)
88
101
  rubocop (~> 1.50)
89
102
  rubocop-rspec (~> 2.22)
103
+ simplecov (~> 0.22)
90
104
  vcr (~> 6.1)
91
105
  webmock (~> 3.8)
92
106
 
data/README.md CHANGED
@@ -9,9 +9,10 @@ The Following environment variables are available to configure the default
9
9
 
10
10
  * `HTTPS_PORT` - the TCP numerical port to bind SSL to.
11
11
  * `ACME_ALLOW_IDENTIFIERS` - A comma separated list of hostnames for provisioning certs
12
+ * `ACME_CONTACT` - URL to contact in case of issues with the account
12
13
  * `ACME_DIRECTORY_URL` - the ACME provider's directory
14
+ * `ACME_HMAC_KEY` - your External Account Binding (EAB) HMAC_KEY for authenticating with the ACME directory above
13
15
  * `ACME_KID` - your External Account Binding (EAB) KID for authenticating with the ACME directory above with an
14
- * `ACME_HMAC_KEY` - your EAB HMAC_KEY for authenticating with the ACME directory above
15
16
  * `ACME_RENEW_BEFORE_SECONDS` - **optional** Start a renewal this number number of seconds before the cert expires. This defaults to 30 days (2592000 seconds)
16
17
  * `ACME_RENEW_BEFORE_FRACTION` - **optional** Start the renewal when this fraction of a cert's valid window is left. This defaults to 0.5, which means when the cert is in the last 50% of its lifespan a renewal is attempted.
17
18
  * `AUTO_CERT_CHECK_EVERY` - **optional** the number of seconds to wait between checking if the certificate has expired. This defaults to 1 hour (3600 seconds)
@@ -58,7 +59,8 @@ regenerated periodically.
58
59
  export ACME_DIRECTORY_URL='https://anchor.dev/autocert-cab3bc/development/x509/ca/acme'
59
60
  export ACME_KID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
60
61
  export ACME_HMAC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
61
-
62
+ 1. Update the [./spec/spec_helper.rb](spec/spec_helper.rb) file with these
63
+ values as the respective `VCR_KID` and `VCR_HMAC_KEY`.
62
64
  1. on the command line execute:
63
65
 
64
66
  $ . .env
@@ -5,8 +5,9 @@ module Anchor
5
5
  # AutoCert Configuration provides a way to configure the AutoCert Manager.
6
6
  #
7
7
  class Configuration
8
- DEFAULT_BEFORE_SECONDS = 60 * 60 * 24 * 30 # 30 days in seconds
9
- DEFAULT_BEFORE_FRACTION = 0.5 # when in the last 50% of the validity window, renew
8
+ DEFAULT_RENEW_BEFORE_SECONDS = 60 * 60 * 24 * 30 # 30 days in seconds
9
+ DEFAULT_RENEW_BEFORE_FRACTION = 0.5 # when in the last 50% of the validity window, renew
10
+ DEFAULT_CHECK_EVERY_SECONDS = 60 * 60 # 1 day in seconds
10
11
 
11
12
  # Note - although it is possible to set change the name of a config, it is
12
13
  # not recommended. The name is used as the key in the Registry, and if a
@@ -14,7 +15,8 @@ module Anchor
14
15
  # change its registry key.
15
16
  attr_accessor :name,
16
17
  :allow_identifiers,
17
- :cache,
18
+ :cache_dir,
19
+ :check_every_seconds,
18
20
  :contact,
19
21
  :directory_url,
20
22
  :external_account_binding,
@@ -30,19 +32,21 @@ module Anchor
30
32
  #
31
33
  def initialize(name:,
32
34
  allow_identifiers: nil,
33
- cache: nil,
35
+ cache_dir: nil,
36
+ check_every_seconds: nil,
34
37
  contact: nil,
35
38
  directory_url: nil,
36
39
  external_account_binding: nil,
37
- renew_before_fraction: DEFAULT_BEFORE_FRACTION,
38
- renew_before_seconds: DEFAULT_BEFORE_SECONDS,
40
+ renew_before_fraction: nil,
41
+ renew_before_seconds: nil,
39
42
  tos_acceptors: nil,
40
43
  work_dir: nil)
41
44
 
42
45
  @name = name
43
46
 
44
47
  @allow_identifiers = allow_identifiers
45
- @cache = cache
48
+ @cache_dir = cache_dir
49
+ @check_every_seconds = check_every_seconds
46
50
  @contact = contact
47
51
  @directory_url = directory_url
48
52
  @external_account_binding = external_account_binding
@@ -70,17 +74,32 @@ module Anchor
70
74
 
71
75
  def validate!
72
76
  @allow_identifiers = prepare_allow_identifiers(@allow_identifiers)
73
- @cache = prepare_cache(@cache)
77
+ @cache_dir = prepare_directory(dir: @cache_dir, property: 'cache_dir')
78
+ @check_every_seconds = prepare_check_every_seconds(@check_every_seconds)
79
+ @contact = prepare_contact(@contact)
74
80
  @directory_url = prepare_directory_url(@directory_url)
75
81
  @external_account_binding = prepare_external_account_binding(@external_account_binding)
76
82
  @renew_before_fraction = prepare_renew_before_fraction(@renew_before_fraction)
77
83
  @renew_before_seconds = prepare_renew_before_seconds(@renew_before_seconds)
78
84
  @tos_acceptors = prepare_tos_acceptors(@tos_acceptors)
79
- @work_dir = prepare_work_dir(@work_dir)
80
-
85
+ @work_dir = prepare_directory(dir: @work_dir, property: 'work_dir')
81
86
  self
82
87
  end
83
88
 
89
+ # Return the fallback identifer for this configuration
90
+
91
+ # look at all the identifiers, strip a leading wildcard off of all of
92
+ # them and then pick the one that has the fewest '.' in it, if there are
93
+ # ties for fewest, pick the first one in the list of ties. A minimum of
94
+ # 2 '.' is required.
95
+ #
96
+ def fallback_identifier
97
+ de_wildcarded = allow_identifiers.map { |i| i.sub(/^\*\./, '') }
98
+ not_tld = de_wildcarded.select { |i| i.count('.') >= 2 }
99
+ ordered = not_tld.sort_by { |i| i.count('.') }
100
+ ordered[0]
101
+ end
102
+
84
103
  private
85
104
 
86
105
  def prepare_allow_identifiers(allow_identifiers)
@@ -95,33 +114,42 @@ module Anchor
95
114
 
96
115
  if prepared.nil? || prepared.empty?
97
116
  raise ConfigurationError,
98
- "The '#{name}' #{self.class} instance has a misconfigured `allow_identifiers` value. " \
99
- 'Set it to a string, or an array of strings, ' \
100
- 'or set the ACME_ALLOW_IDENTIFIERS environment variable to a comma separated list of identifiers.'
117
+ "The '#{name}' #{self.class} instance has a misconfigured " \
118
+ '`allow_identifiers` value. Set it to a string, or an array of strings, ' \
119
+ 'or set the ACME_ALLOW_IDENTIFIERS environment variable ' \
120
+ 'to a comma separated list of identifiers.'
101
121
  end
102
122
 
103
123
  prepared
104
124
  end
105
125
 
106
- def prepare_cache(cache)
107
- return nil if cache.nil?
126
+ def prepare_check_every_seconds(check_every_seconds)
127
+ message = "The '#{name}' #{self.class} instance has a misconfigured " \
128
+ '`check_every_seconds` value. It must be set to an integer > 0, ' \
129
+ 'or set the AUTO_CERT_CHECK_EVERY environment variable.'
108
130
 
109
- unless cache.respond_to?(:read) && cache.respond_to?(:write) && cache.respond_to?(:fetch)
110
- raise ConfigurationError,
111
- "The '#{name}' #{self.class} instance has a misconfigured `cache` value. " \
112
- 'It must be set to an object that responds to `read`, `write`, and `fetch`.'
113
- end
131
+ candidates = [
132
+ check_every_seconds,
133
+ ENV.fetch('AUTO_CERT_CHECK_EVERY', nil),
134
+ DEFAULT_CHECK_EVERY_SECONDS
135
+ ]
136
+
137
+ ensure_positive_integer(candidates, message)
138
+ end
114
139
 
115
- cache
140
+ def prepare_contact(contact)
141
+ contact ||= ENV.fetch('ACME_CONTACT', nil)
142
+
143
+ contact
116
144
  end
117
145
 
118
146
  def prepare_directory_url(directory_url)
147
+ message = "The '#{name}' #{self.class} instance has a misconfigured `directory_url` value. " \
148
+ 'It must be set to a string, or set the ACME_DIRECTORY_URL environment variable.'
149
+
119
150
  directory_url ||= ENV.fetch('ACME_DIRECTORY_URL', nil)
120
- if directory_url.nil?
121
- raise ConfigurationError,
122
- "The '#{name}' #{self.class} instance has a misconfigured `directory_url` value. " \
123
- 'It must be set to a string, or set the ACME_DIRECTORY_URL environment variable.'
124
- end
151
+
152
+ raise ConfigurationError, message if directory_url.nil?
125
153
 
126
154
  directory_url
127
155
  end
@@ -130,39 +158,47 @@ module Anchor
130
158
  kid = ENV.fetch('ACME_KID', nil)
131
159
  hmac_key = ENV.fetch('ACME_HMAC_KEY', nil)
132
160
 
133
- if kid && hmac_key
134
- external_account_binding = {
135
- kid: kid,
136
- hmac_key: hmac_key
137
- }
161
+ if external_account_binding && external_account_binding[:kid] && external_account_binding[:hmac_key]
162
+ return external_account_binding
138
163
  end
139
- external_account_binding
164
+
165
+ { kid: kid, hmac_key: hmac_key }
140
166
  end
141
167
 
142
168
  def prepare_renew_before_seconds(renew_before_seconds)
143
- renew_before_seconds ||= ENV.fetch('ACME_RENEW_BEFORE_SECONDS', nil)
144
- if renew_before_seconds
145
- renew_before_seconds = renew_before_seconds.to_i
146
- unless renew_before_seconds.positive?
147
- raise ConfigurationError,
148
- "The '#{name}' #{self.class} instance has a misconfigured `before_seconds` value. " \
149
- 'It must be set to an integer > 0, or set the ACME_RENEW_BEFORE_SECONDS environment variable.'
150
- end
151
- end
152
- renew_before_seconds
169
+ message = "The '#{name}' #{self.class} instance has a misconfigured " \
170
+ '`before_seconds` value. It must be set to an integer > 0, ' \
171
+ 'or set the ACME_RENEW_BEFORE_SECONDS environment variable.'
172
+
173
+ candidates = [
174
+ renew_before_seconds,
175
+ ENV.fetch('ACME_RENEW_BEFORE_SECONDS', nil),
176
+ DEFAULT_RENEW_BEFORE_SECONDS
177
+ ]
178
+ ensure_positive_integer(candidates, message)
153
179
  end
154
180
 
155
181
  def prepare_renew_before_fraction(renew_before_fraction)
156
- renew_before_fraction ||= ENV.fetch('ACME_RENEW_BEFORE_FRACTION', nil)
157
- if renew_before_fraction
158
- renew_before_fraction = renew_before_fraction.to_f
159
- unless (0..1).cover?(renew_before_fraction)
160
- raise ConfigurationError,
161
- "The '#{name}' #{self.class} instance has a misconfigured `before_fraction` value. " \
162
- 'It must be set to a float > 0 and < 1, or set the ACME_RENEW_BEFORE_FRACTION environment variable.'
163
- end
182
+ message = "The '#{name}' #{self.class} instance has a misconfigured " \
183
+ '`before_fraction` value. It must be set to a float > 0 and < 1, ' \
184
+ 'or set the ACME_RENEW_BEFORE_FRACTION environment variable.'
185
+
186
+ candidates = [
187
+ renew_before_fraction,
188
+ ENV.fetch('ACME_RENEW_BEFORE_FRACTION', nil),
189
+ DEFAULT_RENEW_BEFORE_FRACTION
190
+ ]
191
+
192
+ candidates.each do |candidate|
193
+ next if candidate.nil?
194
+
195
+ as_float = candidate.to_f
196
+ return as_float if (0..1).cover?(as_float)
164
197
  end
165
- renew_before_fraction
198
+
199
+ # this should really never happen as DEFAULT_RENEW_BEFORE_FRACTION is
200
+ # valid
201
+ raise ConfigurationError, message
166
202
  end
167
203
 
168
204
  def prepare_tos_acceptors(tos_acceptors)
@@ -170,29 +206,40 @@ module Anchor
170
206
 
171
207
  if tos_acceptors.empty? || tos_acceptors.any? { |tos| !tos.respond_to?(:accept?) }
172
208
  raise ConfigurationError,
173
- "The '#{name}' #{self.class} instance has a misconfigured `tos_acceptors` value. " \
174
- 'It must be set to an object or an array of objects that respond to `accept?`.'
209
+ "The '#{name}' #{self.class} instance has a misconfigured " \
210
+ '`tos_acceptors` value. It must be set to an object ' \
211
+ 'or an array of objects that respond to `accept?`.'
175
212
  end
176
213
 
177
214
  tos_acceptors
178
215
  end
179
216
 
180
- def prepare_work_dir(work_dir)
181
- return nil if work_dir.nil?
217
+ def prepare_directory(dir:, property:)
218
+ return nil if dir.nil?
182
219
 
183
- work_dir = Pathname.new(work_dir) unless work_dir.is_a?(Pathname)
220
+ dir = Pathname.new(dir) unless dir.is_a?(Pathname)
221
+ message = "The '#{name}' #{self.class} instance has a misconfigured " \
222
+ "`#{property}` value, it resolves to (#{dir}). " \
223
+ 'It must be set to a directory, or a path that can be created.'
184
224
 
185
225
  begin
186
- work_dir.mkpath
187
- rescue StandardError => e
188
- raise ConfigurationError, "#{self.class}#work_dir : #{e.message}"
226
+ dir.mkpath
227
+ rescue StandardError => _e
228
+ raise ConfigurationError, message
189
229
  end
190
230
 
191
- unless work_dir.directory? && work_dir.writable?
192
- raise ConfigurationError, "#{self.class}#work_dir '#{work_dir}' must be a writable directory."
231
+ dir
232
+ end
233
+
234
+ def ensure_positive_integer(candidates, message)
235
+ candidates.each do |candidate|
236
+ next if candidate.nil?
237
+
238
+ as_int = candidate.to_i
239
+ return as_int if as_int.positive?
193
240
  end
194
241
 
195
- work_dir
242
+ raise ConfigurationError, message
196
243
  end
197
244
  end
198
245
  end
@@ -47,7 +47,10 @@ module Anchor
47
47
  check_klass = self.class.policy_checks.find do |klass|
48
48
  klass.handles?(description)
49
49
  end
50
- raise UnknownPolicyCheckError, "Unable to create a policy check based upon '#{description}'" if check_klass.nil?
50
+ if check_klass.nil?
51
+ raise UnknownPolicyCheckError,
52
+ "Unable to create a policy check based upon '#{description}'"
53
+ end
51
54
 
52
55
  @description = description
53
56
  @check = check_klass.new(description)
@@ -4,32 +4,26 @@ require 'forwardable'
4
4
 
5
5
  module Anchor
6
6
  module AutoCert
7
- # ManagedCertificate is a class that represents a certificate and its manager
7
+ # ManagedCertificate is a class that represents a certificate
8
8
  # for renewal
9
9
  class ManagedCertificate
10
- attr_reader :cert_pem, :cert_path, :key_pem, :key_path, :key, :manager, :x509
10
+ attr_reader :cert_pem, :key_pem, :x509, :persist_dir, :cert_path, :private_key_path
11
11
 
12
12
  extend Forwardable
13
- def_delegators :@manager, :enabled?
14
- def_delegators :@x509, :not_after, :not_before, :serial
13
+ def_delegators :@x509, :not_after, :not_before
15
14
 
16
- def self.from(manager:, cert_path:, key_path:)
17
- cert_pem = File.read(cert_path)
18
- key_pem = File.read(key_path)
19
- new(manager: manager, cert_pem: cert_pem, key_pem: key_pem)
15
+ def initialize(cert_pem:, key_pem:, persist_dir: nil)
16
+ @cert_pem = cert_pem
17
+ @key_pem = key_pem
18
+ @persist_dir = Pathname.new(persist_dir) if persist_dir
19
+ @x509 = OpenSSL::X509::Certificate.new(cert_pem)
20
+ @cert_path = nil
21
+ @private_key_path = nil
22
+ persist_pems
20
23
  end
21
24
 
22
- def initialize(manager:, cert_pem:, key_pem:)
23
- @manager = manager
24
- @cert_pem = cert_pem
25
- @key_pem = key_pem
26
- @x509 = OpenSSL::X509::Certificate.new(cert_pem)
27
-
28
- hex_serial_basename = hex_serial('-')
29
- @cert_path = manager.work_dir / "#{hex_serial_basename}.crt"
30
- @key_path = manager.work_dir / "#{hex_serial_basename}.key"
31
-
32
- write_working_files
25
+ def serial
26
+ x509.serial.to_i
33
27
  end
34
28
 
35
29
  def hex_serial(joiner = ':')
@@ -40,27 +34,43 @@ module Anchor
40
34
  not_after <= now
41
35
  end
42
36
 
43
- def needs_renewal?(now = Time.now.utc)
44
- manager.needs_renewal?(cert: x509, now: now)
45
- end
46
-
37
+ # For the moment, the only items in subjectAltName we care about are DNS:
38
+ # entries.
47
39
  def identifiers
48
40
  alt_names = x509&.extensions&.find { |ext| ext.oid == 'subjectAltName' }&.value&.split(', ') || []
49
- alt_names.map { |name| name.sub(/^DNS:/, '') }
41
+ alt_names.select { |name| name.start_with?('DNS:') }
42
+ .map { |name| name.sub(/^DNS:/, '') }
50
43
  end
51
44
 
52
45
  def common_name
53
46
  x509.subject.to_a.find { |name, _, _| name == 'CN' }[1]
54
47
  end
55
48
 
56
- def write_working_files
57
- cert_path.open('w') { |f| f << cert_pem }
58
- key_path.open('w') { |f| f << key_pem }
49
+ def all_names
50
+ non_common_identifiers = identifiers.reject { |name| name == common_name }
51
+ [common_name, *non_common_identifiers.sort]
52
+ end
53
+
54
+ def persist_pems
55
+ return unless persist_dir
56
+ return nil unless persist_dir.directory? && persist_dir.writable?
57
+
58
+ @cert_path = persist_dir.join("#{serial}.crt")
59
+ @cert_path.write(cert_pem)
60
+
61
+ @private_key_path = persist_dir.join("#{serial}.key")
62
+ @private_key_path.write(key_pem)
59
63
  end
60
64
 
61
65
  def purge_working_files
62
- cert_path.delete if cert_path.exist?
63
- key_path.delete if key_path.exist?
66
+ return unless persist_dir
67
+ return nil unless persist_dir.directory? && persist_dir.writable?
68
+
69
+ [@cert_path, @private_key_path].each do |path|
70
+ next unless path&.exist? && path&.writable?
71
+
72
+ path.delete
73
+ end
64
74
  end
65
75
  end
66
76
  end
@@ -15,19 +15,22 @@ module Anchor
15
15
 
16
16
  extend Forwardable
17
17
  def_delegators :@configuration,
18
- :cache,
18
+ :check_every_seconds,
19
+ :cache_dir,
19
20
  :contact,
20
21
  :directory,
21
22
  :external_account_binding,
23
+ :fallback_identifier,
22
24
  :renew_before_fraction,
23
- :renew_before_seconds
25
+ :renew_before_seconds,
26
+ :work_dir
24
27
 
25
- attr_reader :client,
28
+ attr_reader :disk_store,
29
+ :client,
26
30
  :configuration,
27
31
  :directory_url,
28
32
  :identifier_policies,
29
- :tos_acceptors,
30
- :work_dir
33
+ :tos_acceptors
31
34
 
32
35
  def self.for(configuration)
33
36
  new(configuration: configuration)
@@ -37,10 +40,12 @@ module Anchor
37
40
  configuration.validate!
38
41
  @configuration = configuration
39
42
 
43
+ # disk store early since other things may use it
44
+ @disk_store = DiskStore.new(dir: @configuration.cache_dir, basename: 'autocert-manager')
45
+
40
46
  @identifier_policies = IdentifierPolicy.build(@configuration.allow_identifiers)
41
47
  @tos_acceptors = Array(@configuration.tos_acceptors)
42
48
  @directory_url = URI.parse(@configuration.directory_url)
43
- @work_dir = Pathname.new(@configuration.work_dir || Dir.mktmpdir)
44
49
 
45
50
  @account_opts = {
46
51
  contact: @configuration.contact,
@@ -49,30 +54,63 @@ module Anchor
49
54
 
50
55
  @client = client || new_client(contact: @configuration.contact)
51
56
  @enabled = true
57
+ @managed_certificates = {}
52
58
  end
53
59
 
54
60
  # It is currently assumed that the common name is the first of the
55
61
  # `identifiers` passed into this method. If that is not the case, then
56
62
  # the `common_name` parameter needs to be set explicitly
57
- def managed_certificate(identifiers:, algorithm: :ecdsa, common_name: Array(identifiers).first, **opts)
58
- if (arr = denied_identifiers(identifiers)).size.positive?
59
- raise IdentifierNotAllowedError, "denied identifiers'#{arr.join(',')}'"
63
+ def managed_certificate(identifiers:, algorithm: :ecdsa, common_name: Array(identifiers).first,
64
+ now: Time.now.utc, **opts)
65
+ full_ids = consolidate_identifiers(common_name: common_name, identifiers: identifiers)
66
+ denied_ids = denied_identifiers(full_ids)
67
+
68
+ # Fallback to a configured identifier if the requested one(s) are denied
69
+ if denied_ids.any?
70
+ common_name = fallback_identifier
71
+ identifiers = []
60
72
  end
61
73
 
62
- key_pem = cache&.read("#{common_name}+#{algorithm}")
63
- cert_pem = cache&.read(common_name)
74
+ # first look and see if its memory
75
+ managed_certificate = @managed_certificates[common_name]
76
+ if managed_certificate && !needs_renewal?(cert: managed_certificate, now: now)
77
+ return managed_certificate
78
+ end
79
+
80
+ # then look into the disk cache
81
+ if @disk_store
82
+ key_pem = @disk_store["#{common_name}.key.pem"]
83
+ cert_pem = @disk_store["#{common_name}.cert.pem"]
84
+ end
64
85
 
65
86
  if !key_pem.nil? && !cert_pem.nil?
66
- managed_cert = ManagedCertificate.new(manager: self, cert_pem: cert_pem, key_pem: key_pem)
67
- return managed_cert unless managed_cert.expired?
87
+ managed_certificate = ManagedCertificate.new(cert_pem: cert_pem,
88
+ key_pem: key_pem,
89
+ persist_dir: work_dir)
90
+ if managed_certificate && !needs_renewal?(cert: managed_certificate, now: now)
91
+ return managed_certificate
92
+ end
68
93
  end
69
94
 
70
- cert_pem, key_pem = provision(identifiers: identifiers, algorithm: algorithm, common_name: common_name, **opts)
95
+ # and then provision a new one
96
+ cert_pem, key_pem = provision_or_fallback(
97
+ identifiers: identifiers, algorithm: algorithm,
98
+ common_name: common_name,
99
+ **opts
100
+ )
101
+
102
+ managed_certificate = ManagedCertificate.new(
103
+ cert_pem: cert_pem, key_pem: key_pem, persist_dir: work_dir
104
+ )
71
105
 
72
- cache&.write("#{common_name}+#{algorithm}", key_pem)
73
- cache&.write(common_name, cert_pem)
106
+ @managed_certificates[common_name] = managed_certificate
74
107
 
75
- ManagedCertificate.new(manager: self, cert_pem: cert_pem, key_pem: key_pem)
108
+ if @disk_store
109
+ @disk_store["#{common_name}.key.pem"] = key_pem
110
+ @disk_store["#{common_name}.cert.pem"] = cert_pem
111
+ end
112
+
113
+ managed_certificate
76
114
  end
77
115
 
78
116
  def needs_renewal?(cert:, now: Time.now.utc)
@@ -97,12 +135,31 @@ module Anchor
97
135
 
98
136
  private
99
137
 
138
+ def provision_or_fallback(identifiers:, algorithm:, common_name:, **opts)
139
+ cert_pem = nil
140
+ key_pem = nil
141
+ begin
142
+ cert_pem, key_pem = provision(
143
+ identifiers: identifiers, algorithm: algorithm, common_name: common_name,
144
+ **opts
145
+ )
146
+ rescue StandardError => _e
147
+ cert_pem, key_pem = provision(
148
+ identifiers: [], algorithm: algorithm, common_name: fallback_identifier,
149
+ **opts
150
+ )
151
+ end
152
+ [cert_pem, key_pem]
153
+ end
154
+
100
155
  def provision(identifiers:, algorithm:, common_name:, **opts)
101
- identifiers = Array(identifiers)
156
+ identifiers = consolidate_identifiers(common_name: common_name, identifiers: identifiers)
102
157
  load_or_build_account
103
158
  key_pem ||= new_key(algorithm).to_pem
104
- csr = Acme::Client::CertificateRequest.new(private_key: parse_key_pem(key_pem), common_name: common_name,
105
- names: identifiers)
159
+ csr = Acme::Client::CertificateRequest.new(
160
+ common_name: common_name, names: identifiers,
161
+ private_key: parse_key_pem(key_pem)
162
+ )
106
163
 
107
164
  order = @client.new_order(identifiers: identifiers, **opts)
108
165
  order.finalize(csr: csr)
@@ -154,12 +211,13 @@ module Anchor
154
211
  end
155
212
 
156
213
  def new_client(account_key: nil, contact: nil, **)
157
- account_key ||= fetch_account_key(contact) { new_key(:ecdsa) }
214
+ account_key ||= account_key_for(contact)
158
215
 
159
216
  Acme::Client.new(private_key: account_key, directory: @directory_url)
160
217
  end
161
218
 
162
- def new_key(algorithm)
219
+ # currently only using ecdsa algorithm
220
+ def new_key(algorithm = :ecdsa)
163
221
  case algorithm
164
222
  when :ecdsa then OpenSSL::PKey::EC.generate('prime256v1')
165
223
  else
@@ -167,25 +225,19 @@ module Anchor
167
225
  end
168
226
  end
169
227
 
170
- def fetch_account_key(contact)
171
- id = "#{contact || 'default'}+#{@directory_url.host}+key"
172
- parse_key_pem(cache&.fetch(id) { parse_key_pem(yield.to_pem) } || yield.to_pem)
173
- end
228
+ def account_key_for(contact)
229
+ return new_key unless @disk_store
174
230
 
175
- def parse_key_pem(data)
176
- key_pem = parse_rsa_pem(data) || parse_ecdsa_pem(data)
177
- raise UnknownKeyFormatError unless key_pem
231
+ account_key_id = "#{contact || 'default'}+#{@directory_url}+key"
232
+ pem = @disk_store[account_key_id]
233
+ return parse_key_pem(pem) if pem
178
234
 
179
- key_pem
235
+ raw_key = new_key
236
+ @disk_store[account_key_id] = raw_key.to_pem
237
+ raw_key
180
238
  end
181
239
 
182
- def parse_rsa_pem(data)
183
- OpenSSL::PKey::RSA.new(data)
184
- rescue StandardError
185
- nil
186
- end
187
-
188
- def parse_ecdsa_pem(data)
240
+ def parse_key_pem(data)
189
241
  OpenSSL::PKey::EC.new(data)
190
242
  rescue StandardError
191
243
  nil
@@ -197,6 +249,12 @@ module Anchor
197
249
  @identifier_policies.any? { |policy| policy.allow?(identifier) }
198
250
  end
199
251
  end
252
+
253
+ # return a list of identifiers with duplicates removed
254
+ # preserving order with the common_name first
255
+ def consolidate_identifiers(common_name:, identifiers: [])
256
+ [common_name, *identifiers].compact.uniq
257
+ end
200
258
  end
201
259
  end
202
260
  end
@@ -39,6 +39,9 @@ module Anchor
39
39
  # to the `config.hosts` then HostAuthorization will be used, and tests
40
40
  # will break.
41
41
  unless Rails.env.test?
42
+ # load values from ENV
43
+ auto_cert_config&.validate! if Rails.configuration.auto_cert.enabled?
44
+
42
45
  auto_cert_config&.allow_identifiers&.each do |identifier|
43
46
  # need to convert an identifier into a host matcher, which is just
44
47
  # strip off a leading '*' if it exists so that all subdomains match.
@@ -67,7 +70,7 @@ module Anchor
67
70
  # set explicitly.
68
71
  acme_scratch_dir = app.root / 'tmp' / 'acme'
69
72
  acme_scratch_dir.mkpath
70
- auto_cert_config.cache ||= ActiveSupport::Cache::FileStore.new(acme_scratch_dir / 'cache')
73
+ auto_cert_config.cache_dir ||= (acme_scratch_dir / 'cache')
71
74
  auto_cert_config.work_dir ||= (acme_scratch_dir / 'work')
72
75
 
73
76
  auto_cert_config
@@ -16,19 +16,20 @@ module Anchor
16
16
  class RenewalBusyWait
17
17
  ONE_HOUR = 60 * 60
18
18
 
19
- def self.wait_for_it(managed_certificate:, check_every: ONE_HOUR, &keep_going)
20
- waiter = new(managed_certificate: managed_certificate, check_every: check_every)
19
+ def self.wait_for_it(manager:, managed_certificate:, check_every: ONE_HOUR, &keep_going)
20
+ waiter = new(manager: manager, managed_certificate: managed_certificate, check_every: check_every)
21
21
  waiter.wait_for_it(&keep_going)
22
22
  end
23
23
 
24
- def initialize(managed_certificate:, check_every: ONE_HOUR)
24
+ def initialize(manager:, managed_certificate:, check_every: ONE_HOUR)
25
+ @manager = manager
25
26
  @managed_certificate = managed_certificate
26
27
  @check_every = check_every
27
28
  end
28
29
 
29
30
  def wait_for_it
30
31
  loop do
31
- break if @managed_certificate.needs_renewal?
32
+ break if @manager.needs_renewal?(cert: @managed_certificate)
32
33
  break unless yield
33
34
 
34
35
  sleep @check_every
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pstore'
4
+ require 'tempfile'
5
+
6
+ module Anchor
7
+ # DiskStore is a simple key/value store that persists to disk using PStore.
8
+ #
9
+ class DiskStore
10
+ def initialize(dir: nil, basename: 'anchor-disk-store')
11
+ @dir = dir || Dir.mktmpdir
12
+ @basename = basename
13
+ @path = File.join(@dir, @basename)
14
+ @pstore = PStore.new(@path, true)
15
+ end
16
+
17
+ def [](key)
18
+ data = nil
19
+ @pstore.transaction do
20
+ data = @pstore[key]
21
+ end
22
+ data
23
+ end
24
+
25
+ def []=(key, value)
26
+ @pstore.transaction do
27
+ @pstore[key] = value
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ # PEMBundle is a collection of PEM encoded certificates. It can be written
5
+ # to a temporarly file on disk as a bundle if needed.
6
+ #
7
+ # This temp file to disk is needed for some other libraries that require a
8
+ # path to a pem file, and not a string of pem encoded certificates.
9
+ #
10
+ class PemBundle
11
+ DEFAULT_BASENAME = 'bundle.pem'
12
+
13
+ def initialize(pems: [])
14
+ @pems = pems || []
15
+ @path = nil
16
+ @temp_dir = nil
17
+ @basename = DEFAULT_BASENAME
18
+ end
19
+
20
+ def pems
21
+ @pems.dup
22
+ end
23
+
24
+ def path
25
+ write
26
+ @path
27
+ end
28
+
29
+ def with_path
30
+ yield(path)
31
+ ensure
32
+ remove_path
33
+ end
34
+
35
+ def add_cert(cert)
36
+ @pems << cert
37
+ remove_path
38
+ end
39
+
40
+ def to_s
41
+ @pems.join("\n")
42
+ end
43
+
44
+ def clear
45
+ @pems.clear
46
+ remove_path
47
+ end
48
+
49
+ private
50
+
51
+ def temp_path
52
+ @temp_dir = Dir.mktmpdir
53
+ File.join(@temp_dir, @basename)
54
+ end
55
+
56
+ def ensure_path
57
+ return @path if @path
58
+
59
+ @path = temp_path
60
+ end
61
+
62
+ def write
63
+ remove_path
64
+ ensure_path
65
+ File.open(@path, 'w+', 0o400) do |io|
66
+ io.write(to_s)
67
+ end
68
+ end
69
+
70
+ def remove_path
71
+ File.unlink(@path) if @path && File.exist?(@path)
72
+
73
+ if @temp_dir && Dir.exist?(@temp_dir)
74
+ FileUtils.remove_entry_secure(@temp_dir)
75
+ @temp_dir = nil
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anchor
4
- VERSION = '0.5.0'
4
+ VERSION = '0.6.1'
5
5
  end
data/lib/anchor.rb CHANGED
@@ -22,3 +22,5 @@ end
22
22
  require_relative 'anchor/version'
23
23
  require_relative 'anchor/auto_cert'
24
24
  require_relative 'anchor/oid'
25
+ require_relative 'anchor/pem_bundle'
26
+ require_relative 'anchor/disk_store'
@@ -13,7 +13,7 @@ module Puma
13
13
  def ssl_bind_options(managed_certificate:)
14
14
  {
15
15
  cert: managed_certificate.cert_path,
16
- key: managed_certificate.key_path
16
+ key: managed_certificate.private_key_path
17
17
  }
18
18
  end
19
19
  end
@@ -22,32 +22,34 @@ module Puma
22
22
  # a plugin is created
23
23
  module PluginInstanceMethods
24
24
  attr_accessor :managed_certificate
25
- attr_reader :port
25
+ attr_reader :manager, :port
26
26
 
27
27
  def config(dsl)
28
28
  @port = dsl.auto_cert_port || ENV.fetch('HTTPS_PORT', nil)
29
29
  name = dsl.auto_cert_name || ENV.fetch('AUTO_CERT_NAME', 'default')
30
30
  configuration = ::Anchor::AutoCert::Registry.fetch(name)
31
31
  identifiers = configuration.allow_identifiers
32
- manager = ::Anchor::AutoCert::Manager.new(configuration: configuration)
32
+ @manager = ::Anchor::AutoCert::Manager.new(configuration: configuration)
33
33
 
34
34
  @managed_certificate = manager.managed_certificate(identifiers: identifiers)
35
-
36
- options = ::Puma::Plugin::AutoCert.ssl_bind_options(managed_certificate: @managed_certificate)
37
-
38
- dsl.ssl_bind '[::]', port, options
39
- rescue StandardError
35
+ rescue StandardError => _e
36
+ @manager = nil
40
37
  @managed_certificate = nil
41
38
  end
42
39
 
43
40
  def start(launcher)
44
41
  @launcher = launcher
45
- unless managed_certificate&.enabled?
42
+ unless manager&.enabled? && managed_certificate
46
43
  log_writer.log 'AutoCert >> Not enabled - skipping certificate renewal process'
47
44
  return
48
45
  end
49
46
 
50
- @managed_certificate.identifiers.each do |identifier|
47
+ options = ::Puma::Plugin::AutoCert.ssl_bind_options(managed_certificate: managed_certificate)
48
+ launcher.config.configure do |_user_config, file_config|
49
+ file_config.ssl_bind '[::]', port, options
50
+ end
51
+
52
+ managed_certificate.identifiers.each do |identifier|
51
53
  log_writer.log "AutoCert >> Available at https://#{identifier}:#{port}/"
52
54
  end
53
55
 
@@ -56,7 +58,8 @@ module Puma
56
58
  ::Anchor::AutoCert::RenewalBusyWait::ONE_HOUR
57
59
 
58
60
  in_background do
59
- Anchor::AutoCert::RenewalBusyWait.wait_for_it(managed_certificate: managed_certificate,
61
+ Anchor::AutoCert::RenewalBusyWait.wait_for_it(manager: manager,
62
+ managed_certificate: managed_certificate,
60
63
  check_every: check_every) do
61
64
  dump_cert_info
62
65
 
@@ -71,6 +74,8 @@ module Puma
71
74
  log_writer.log 'AutoCert >> Restarting Puma in order to renew certificate'
72
75
  @launcher.restart
73
76
  end
77
+ rescue StandardError => e
78
+ log_writer.log "AutoCert >> Error - #{e.message}"
74
79
  end
75
80
 
76
81
  private
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: anchor-pki
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Anchor Security, Inc
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-09-18 00:00:00.000000000 Z
11
+ date: 2023-12-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: acme-client
@@ -24,6 +24,20 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: 2.0.13
27
+ - !ruby/object:Gem::Dependency
28
+ name: pstore
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.1'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: minitest
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -94,6 +108,20 @@ dependencies:
94
108
  - - "~>"
95
109
  - !ruby/object:Gem::Version
96
110
  version: '2.22'
111
+ - !ruby/object:Gem::Dependency
112
+ name: simplecov
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '0.22'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '0.22'
97
125
  - !ruby/object:Gem::Dependency
98
126
  name: vcr
99
127
  requirement: !ruby/object:Gem::Requirement
@@ -152,7 +180,9 @@ files:
152
180
  - lib/anchor/auto_cert/registry.rb
153
181
  - lib/anchor/auto_cert/renewal_busy_wait.rb
154
182
  - lib/anchor/auto_cert/terms_of_service_acceptor.rb
183
+ - lib/anchor/disk_store.rb
155
184
  - lib/anchor/oid.rb
185
+ - lib/anchor/pem_bundle.rb
156
186
  - lib/anchor/version.rb
157
187
  - lib/puma/dsl.rb
158
188
  - lib/puma/plugin/auto_cert.rb