anchor-pki 0.5.0 → 0.6.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c07e54ede7ccff887e6023fe9842989720fdf9d63f159e4a5a3b4ba1a92c6c1a
4
- data.tar.gz: dc0ba249f2125b9cc9d6a4a2dfda9fb042e86c0c7691d45fd6f7b432c5aa2d43
3
+ metadata.gz: 2fe888d3160e743df15731de957658f18c7a31a1805d29d6d95d1364c03ceb73
4
+ data.tar.gz: d264ec5e53c951ceb0c8a5214479a9dd36933f220e05671f88b2029c418f00a3
5
5
  SHA512:
6
- metadata.gz: e074e115e1035f8ed0b2289859073516b9cad04f9452f5dd8aa8dc070b8cdd119db2abf26b5bf2540a410818948fd4dfff4ca854cdc579b457bf15ae4065428a
7
- data.tar.gz: 916f262c31580a3f92ca909bd0900d979a9ccb7a93d3f2cdb9e538bc9b29e8215db96468b5fe21c49c16ed836260857cc392f697772f032e6f76c28540af0653
6
+ metadata.gz: 7dafa50e3a537fe79c29fedb256e9a4986feceb78cf81cd0afb4c30b14eacee84fdd5c952ed7e34866b96711d4d4f390b00ba91ef3ff0e7c8abab3b4018b1858
7
+ data.tar.gz: d9315cdc04dd7dd45dbdb223a31e392113c909f56a42d94981222196c68b90adea48d142847fa2b246655527a9c3f6cf18a53e26f0f7b9e5f6ec930d8dbd8363
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.5.0)
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.11)
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,6 +32,7 @@ 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
38
  rake (13.0.6)
@@ -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)
@@ -87,6 +98,7 @@ DEPENDENCIES
87
98
  rspec (~> 3.9)
88
99
  rubocop (~> 1.50)
89
100
  rubocop-rspec (~> 2.22)
101
+ simplecov (~> 0.22)
90
102
  vcr (~> 6.1)
91
103
  webmock (~> 3.8)
92
104
 
data/README.md CHANGED
@@ -58,7 +58,8 @@ regenerated periodically.
58
58
  export ACME_DIRECTORY_URL='https://anchor.dev/autocert-cab3bc/development/x509/ca/acme'
59
59
  export ACME_KID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
60
60
  export ACME_HMAC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
61
-
61
+ 1. Update the [./spec/spec_helper.rb](spec/spec_helper.rb) file with these
62
+ values as the respective `VCR_KID` and `VCR_HMAC_KEY`.
62
63
  1. on the command line execute:
63
64
 
64
65
  $ . .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,31 @@ 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)
74
79
  @directory_url = prepare_directory_url(@directory_url)
75
80
  @external_account_binding = prepare_external_account_binding(@external_account_binding)
76
81
  @renew_before_fraction = prepare_renew_before_fraction(@renew_before_fraction)
77
82
  @renew_before_seconds = prepare_renew_before_seconds(@renew_before_seconds)
78
83
  @tos_acceptors = prepare_tos_acceptors(@tos_acceptors)
79
- @work_dir = prepare_work_dir(@work_dir)
80
-
84
+ @work_dir = prepare_directory(dir: @work_dir, property: 'work_dir')
81
85
  self
82
86
  end
83
87
 
88
+ # Return the fallback identifer for this configuration
89
+
90
+ # look at all the identifiers, strip a leading wildcard off of all of
91
+ # them and then pick the one that has the fewest '.' in it, if there are
92
+ # ties for fewest, pick the first one in the list of ties. A minimum of
93
+ # 2 '.' is required.
94
+ #
95
+ def fallback_identifier
96
+ de_wildcarded = allow_identifiers.map { |i| i.sub(/^\*\./, '') }
97
+ not_tld = de_wildcarded.select { |i| i.count('.') >= 2 }
98
+ ordered = not_tld.sort_by { |i| i.count('.') }
99
+ ordered[0]
100
+ end
101
+
84
102
  private
85
103
 
86
104
  def prepare_allow_identifiers(allow_identifiers)
@@ -95,33 +113,36 @@ module Anchor
95
113
 
96
114
  if prepared.nil? || prepared.empty?
97
115
  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.'
116
+ "The '#{name}' #{self.class} instance has a misconfigured " \
117
+ '`allow_identifiers` value. Set it to a string, or an array of strings, ' \
118
+ 'or set the ACME_ALLOW_IDENTIFIERS environment variable ' \
119
+ 'to a comma separated list of identifiers.'
101
120
  end
102
121
 
103
122
  prepared
104
123
  end
105
124
 
106
- def prepare_cache(cache)
107
- return nil if cache.nil?
125
+ def prepare_check_every_seconds(check_every_seconds)
126
+ message = "The '#{name}' #{self.class} instance has a misconfigured " \
127
+ '`check_every_seconds` value. It must be set to an integer > 0, ' \
128
+ 'or set the AUTO_CERT_CHECK_EVERY environment variable.'
108
129
 
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
130
+ candidates = [
131
+ check_every_seconds,
132
+ ENV.fetch('AUTO_CERT_CHECK_EVERY', nil),
133
+ DEFAULT_CHECK_EVERY_SECONDS
134
+ ]
114
135
 
115
- cache
136
+ ensure_positive_integer(candidates, message)
116
137
  end
117
138
 
118
139
  def prepare_directory_url(directory_url)
140
+ message = "The '#{name}' #{self.class} instance has a misconfigured `directory_url` value. " \
141
+ 'It must be set to a string, or set the ACME_DIRECTORY_URL environment variable.'
142
+
119
143
  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
144
+
145
+ raise ConfigurationError, message if directory_url.nil?
125
146
 
126
147
  directory_url
127
148
  end
@@ -130,39 +151,47 @@ module Anchor
130
151
  kid = ENV.fetch('ACME_KID', nil)
131
152
  hmac_key = ENV.fetch('ACME_HMAC_KEY', nil)
132
153
 
133
- if kid && hmac_key
134
- external_account_binding = {
135
- kid: kid,
136
- hmac_key: hmac_key
137
- }
154
+ if external_account_binding && external_account_binding[:kid] && external_account_binding[:hmac_key]
155
+ return external_account_binding
138
156
  end
139
- external_account_binding
157
+
158
+ { kid: kid, hmac_key: hmac_key }
140
159
  end
141
160
 
142
161
  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
162
+ message = "The '#{name}' #{self.class} instance has a misconfigured " \
163
+ '`before_seconds` value. It must be set to an integer > 0, ' \
164
+ 'or set the ACME_RENEW_BEFORE_SECONDS environment variable.'
165
+
166
+ candidates = [
167
+ renew_before_seconds,
168
+ ENV.fetch('ACME_RENEW_BEFORE_SECONDS', nil),
169
+ DEFAULT_RENEW_BEFORE_SECONDS
170
+ ]
171
+ ensure_positive_integer(candidates, message)
153
172
  end
154
173
 
155
174
  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
175
+ message = "The '#{name}' #{self.class} instance has a misconfigured " \
176
+ '`before_fraction` value. It must be set to a float > 0 and < 1, ' \
177
+ 'or set the ACME_RENEW_BEFORE_FRACTION environment variable.'
178
+
179
+ candidates = [
180
+ renew_before_fraction,
181
+ ENV.fetch('ACME_RENEW_BEFORE_FRACTION', nil),
182
+ DEFAULT_RENEW_BEFORE_FRACTION
183
+ ]
184
+
185
+ candidates.each do |candidate|
186
+ next if candidate.nil?
187
+
188
+ as_float = candidate.to_f
189
+ return as_float if (0..1).cover?(as_float)
164
190
  end
165
- renew_before_fraction
191
+
192
+ # this should really never happen as DEFAULT_RENEW_BEFORE_FRACTION is
193
+ # valid
194
+ raise ConfigurationError, message
166
195
  end
167
196
 
168
197
  def prepare_tos_acceptors(tos_acceptors)
@@ -170,29 +199,40 @@ module Anchor
170
199
 
171
200
  if tos_acceptors.empty? || tos_acceptors.any? { |tos| !tos.respond_to?(:accept?) }
172
201
  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?`.'
202
+ "The '#{name}' #{self.class} instance has a misconfigured " \
203
+ '`tos_acceptors` value. It must be set to an object ' \
204
+ 'or an array of objects that respond to `accept?`.'
175
205
  end
176
206
 
177
207
  tos_acceptors
178
208
  end
179
209
 
180
- def prepare_work_dir(work_dir)
181
- return nil if work_dir.nil?
210
+ def prepare_directory(dir:, property:)
211
+ return nil if dir.nil?
182
212
 
183
- work_dir = Pathname.new(work_dir) unless work_dir.is_a?(Pathname)
213
+ dir = Pathname.new(dir) unless dir.is_a?(Pathname)
214
+ message = "The '#{name}' #{self.class} instance has a misconfigured " \
215
+ "`#{property}` value, it resolves to (#{dir}). " \
216
+ 'It must be set to a directory, or a path that can be created.'
184
217
 
185
218
  begin
186
- work_dir.mkpath
187
- rescue StandardError => e
188
- raise ConfigurationError, "#{self.class}#work_dir : #{e.message}"
219
+ dir.mkpath
220
+ rescue StandardError => _e
221
+ raise ConfigurationError, message
189
222
  end
190
223
 
191
- unless work_dir.directory? && work_dir.writable?
192
- raise ConfigurationError, "#{self.class}#work_dir '#{work_dir}' must be a writable directory."
224
+ dir
225
+ end
226
+
227
+ def ensure_positive_integer(candidates, message)
228
+ candidates.each do |candidate|
229
+ next if candidate.nil?
230
+
231
+ as_int = candidate.to_i
232
+ return as_int if as_int.positive?
193
233
  end
194
234
 
195
- work_dir
235
+ raise ConfigurationError, message
196
236
  end
197
237
  end
198
238
  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,59 @@ 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
+ return managed_certificate if managed_certificate && !needs_renewal?(cert: managed_certificate, now: now)
77
+
78
+ # then look into the disk cache
79
+ if @disk_store
80
+ key_pem = @disk_store["#{common_name}.key.pem"]
81
+ cert_pem = @disk_store["#{common_name}.cert.pem"]
82
+ end
64
83
 
65
84
  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?
85
+ managed_certificate = ManagedCertificate.new(cert_pem: cert_pem,
86
+ key_pem: key_pem,
87
+ persist_dir: work_dir)
88
+ if managed_certificate && !needs_renewal?(cert: managed_certificate, now: now)
89
+ return managed_certificate
90
+ end
68
91
  end
69
92
 
70
- cert_pem, key_pem = provision(identifiers: identifiers, algorithm: algorithm, common_name: common_name, **opts)
93
+ # and then provision a new one
94
+ cert_pem, key_pem = provision_or_fallback(
95
+ identifiers: identifiers, algorithm: algorithm,
96
+ common_name: common_name,
97
+ **opts
98
+ )
71
99
 
72
- cache&.write("#{common_name}+#{algorithm}", key_pem)
73
- cache&.write(common_name, cert_pem)
100
+ managed_certificate = ManagedCertificate.new(cert_pem: cert_pem, key_pem: key_pem, persist_dir: work_dir)
74
101
 
75
- ManagedCertificate.new(manager: self, cert_pem: cert_pem, key_pem: key_pem)
102
+ @managed_certificates[common_name] = managed_certificate
103
+
104
+ if @disk_store
105
+ @disk_store["#{common_name}.key.pem"] = key_pem
106
+ @disk_store["#{common_name}.cert.pem"] = cert_pem
107
+ end
108
+
109
+ managed_certificate
76
110
  end
77
111
 
78
112
  def needs_renewal?(cert:, now: Time.now.utc)
@@ -97,12 +131,25 @@ module Anchor
97
131
 
98
132
  private
99
133
 
134
+ def provision_or_fallback(identifiers:, algorithm:, common_name:, **opts)
135
+ cert_pem = nil
136
+ key_pem = nil
137
+ begin
138
+ cert_pem, key_pem = provision(identifiers: identifiers, algorithm: algorithm, common_name: common_name,
139
+ **opts)
140
+ rescue StandardError => _e
141
+ cert_pem, key_pem = provision(identifiers: [], algorithm: algorithm, common_name: fallback_identifier,
142
+ **opts)
143
+ end
144
+ [cert_pem, key_pem]
145
+ end
146
+
100
147
  def provision(identifiers:, algorithm:, common_name:, **opts)
101
- identifiers = Array(identifiers)
148
+ identifiers = consolidate_identifiers(common_name: common_name, identifiers: identifiers)
102
149
  load_or_build_account
103
150
  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)
151
+ csr = Acme::Client::CertificateRequest.new(common_name: common_name, names: identifiers,
152
+ private_key: parse_key_pem(key_pem))
106
153
 
107
154
  order = @client.new_order(identifiers: identifiers, **opts)
108
155
  order.finalize(csr: csr)
@@ -154,12 +201,13 @@ module Anchor
154
201
  end
155
202
 
156
203
  def new_client(account_key: nil, contact: nil, **)
157
- account_key ||= fetch_account_key(contact) { new_key(:ecdsa) }
204
+ account_key ||= account_key_for(contact)
158
205
 
159
206
  Acme::Client.new(private_key: account_key, directory: @directory_url)
160
207
  end
161
208
 
162
- def new_key(algorithm)
209
+ # currently only using ecdsa algorithm
210
+ def new_key(algorithm = :ecdsa)
163
211
  case algorithm
164
212
  when :ecdsa then OpenSSL::PKey::EC.generate('prime256v1')
165
213
  else
@@ -167,25 +215,19 @@ module Anchor
167
215
  end
168
216
  end
169
217
 
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
218
+ def account_key_for(contact)
219
+ return new_key unless @disk_store
174
220
 
175
- def parse_key_pem(data)
176
- key_pem = parse_rsa_pem(data) || parse_ecdsa_pem(data)
177
- raise UnknownKeyFormatError unless key_pem
221
+ account_key_id = "#{contact || 'default'}+#{@directory_url}+key"
222
+ pem = @disk_store[account_key_id]
223
+ return parse_key_pem(pem) if pem
178
224
 
179
- key_pem
225
+ raw_key = new_key
226
+ @disk_store[account_key_id] = raw_key.to_pem
227
+ raw_key
180
228
  end
181
229
 
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)
230
+ def parse_key_pem(data)
189
231
  OpenSSL::PKey::EC.new(data)
190
232
  rescue StandardError
191
233
  nil
@@ -197,6 +239,12 @@ module Anchor
197
239
  @identifier_policies.any? { |policy| policy.allow?(identifier) }
198
240
  end
199
241
  end
242
+
243
+ # return a list of identifiers with duplicates removed
244
+ # preserving order with the common_name first
245
+ def consolidate_identifiers(common_name:, identifiers: [])
246
+ [common_name, *identifiers].compact.uniq
247
+ end
200
248
  end
201
249
  end
202
250
  end
@@ -67,7 +67,7 @@ module Anchor
67
67
  # set explicitly.
68
68
  acme_scratch_dir = app.root / 'tmp' / 'acme'
69
69
  acme_scratch_dir.mkpath
70
- auto_cert_config.cache ||= ActiveSupport::Cache::FileStore.new(acme_scratch_dir / 'cache')
70
+ auto_cert_config.cache_dir ||= (acme_scratch_dir / 'cache')
71
71
  auto_cert_config.work_dir ||= (acme_scratch_dir / 'work')
72
72
 
73
73
  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.0'
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,33 @@ 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
35
 
36
36
  options = ::Puma::Plugin::AutoCert.ssl_bind_options(managed_certificate: @managed_certificate)
37
37
 
38
38
  dsl.ssl_bind '[::]', port, options
39
- rescue StandardError
39
+ rescue StandardError => _e
40
+ @manager = nil
40
41
  @managed_certificate = nil
41
42
  end
42
43
 
43
44
  def start(launcher)
44
45
  @launcher = launcher
45
- unless managed_certificate&.enabled?
46
+ unless manager&.enabled? && managed_certificate
46
47
  log_writer.log 'AutoCert >> Not enabled - skipping certificate renewal process'
47
48
  return
48
49
  end
49
50
 
50
- @managed_certificate.identifiers.each do |identifier|
51
+ managed_certificate.identifiers.each do |identifier|
51
52
  log_writer.log "AutoCert >> Available at https://#{identifier}:#{port}/"
52
53
  end
53
54
 
@@ -56,7 +57,8 @@ module Puma
56
57
  ::Anchor::AutoCert::RenewalBusyWait::ONE_HOUR
57
58
 
58
59
  in_background do
59
- Anchor::AutoCert::RenewalBusyWait.wait_for_it(managed_certificate: managed_certificate,
60
+ Anchor::AutoCert::RenewalBusyWait.wait_for_it(manager: manager,
61
+ managed_certificate: managed_certificate,
60
62
  check_every: check_every) do
61
63
  dump_cert_info
62
64
 
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.0
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-11-29 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