anchor-pki 0.5.0 → 0.6.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
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