anchor-pki 0.2.0 → 0.4.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: cc0b9b7fe9893fc5d4f23157bbb2a18c4a15066e929024c197b610ef5f6ffd80
4
- data.tar.gz: 5cc3e63d7acbc16531c9abf60d04d205fe5ed0b638e595653a8aed74d8abc49a
3
+ metadata.gz: 342dadd5c28835816da2c07cdd1d4b7b546cc2a35ca80738cc98707d2a048cd4
4
+ data.tar.gz: 97cb3d7c9c9bbb770c8a3a52eae0690bc7f18cadd5f3a31689d6d00239f5a523
5
5
  SHA512:
6
- metadata.gz: 7d5d6bb50ee250800df59aa17ecca780cddc08293ff4bb609a4b944fcc611ef76ec916d5d6b75026694b0740addeabf592533c4117ea90cd3a1032c21c816f5e
7
- data.tar.gz: 4e2c6f2a836c59319a6cab2a6f48204d75ae7e8fb1b721e6177c2ec75be57c1f8271a18fef02afc847dc822b1db8e70c228c4cbd90ec023fd54bd8ecd620d87c
6
+ metadata.gz: 06b0f93d4bc1962f60a4122bd2f4a046818e36c49779eae9255ff24d6cbb7ba9bd9029bb4f72f516b96c5fa8aef98cea7fbbd6f29ae3b69e42e6cb31e5990ade
7
+ data.tar.gz: f220a29a0c170f74b8926a1ae804f65d1719a4f9956edc04c6844ac27b2404f15c56908cf5b608a35fec1fe10679b74442ace2b3f6034af3eab814d564da670d
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.4.0] - 2023-06-06
4
+
5
+ - add a puma plugin and configuration dsl for better integration
6
+ - auto restart of puma when a certificate is renewed
7
+ - improve tests
8
+ - internal refactor to support the puma plugin
9
+
10
+ ## [0.3.0] - 2023-06-05
11
+
12
+ - improve gem packaging
13
+ - extract out a configuration class for Acme
14
+ - add tests
15
+
16
+ ## [0.2.0] - 2023-04-18
17
+
18
+ -
19
+
20
+ ## [0.1.0] - 2021-11-05
21
+
22
+ - Initial release
data/Gemfile ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in anchor-pki.gemspec
6
+ gemspec
data/Gemfile.lock ADDED
@@ -0,0 +1,94 @@
1
+ PATH
2
+ remote: .
3
+ specs:
4
+ anchor-pki (0.2.1)
5
+ acme-client (~> 2.0.13)
6
+
7
+ GEM
8
+ remote: https://rubygems.org/
9
+ specs:
10
+ acme-client (2.0.13)
11
+ faraday (>= 1.0, < 3.0.0)
12
+ faraday-retry (>= 1.0, < 3.0.0)
13
+ addressable (2.8.4)
14
+ public_suffix (>= 2.0.2, < 6.0)
15
+ ast (2.4.2)
16
+ crack (0.4.5)
17
+ rexml
18
+ diff-lcs (1.5.0)
19
+ faraday (2.7.5)
20
+ faraday-net_http (>= 2.0, < 3.1)
21
+ ruby2_keywords (>= 0.0.4)
22
+ faraday-net_http (3.0.2)
23
+ faraday-retry (2.2.0)
24
+ faraday (~> 2.0)
25
+ hashdiff (1.0.1)
26
+ json (2.6.3)
27
+ minitest (5.18.0)
28
+ parallel (1.23.0)
29
+ parser (3.2.2.1)
30
+ ast (~> 2.4.1)
31
+ public_suffix (5.0.1)
32
+ rainbow (3.1.1)
33
+ rake (13.0.6)
34
+ regexp_parser (2.8.0)
35
+ rexml (3.2.5)
36
+ rspec (3.12.0)
37
+ rspec-core (~> 3.12.0)
38
+ rspec-expectations (~> 3.12.0)
39
+ rspec-mocks (~> 3.12.0)
40
+ rspec-core (3.12.2)
41
+ rspec-support (~> 3.12.0)
42
+ rspec-expectations (3.12.3)
43
+ diff-lcs (>= 1.2.0, < 2.0)
44
+ rspec-support (~> 3.12.0)
45
+ rspec-mocks (3.12.5)
46
+ diff-lcs (>= 1.2.0, < 2.0)
47
+ rspec-support (~> 3.12.0)
48
+ rspec-support (3.12.0)
49
+ rubocop (1.51.0)
50
+ json (~> 2.3)
51
+ parallel (~> 1.10)
52
+ parser (>= 3.2.0.0)
53
+ rainbow (>= 2.2.2, < 4.0)
54
+ regexp_parser (>= 1.8, < 3.0)
55
+ rexml (>= 3.2.5, < 4.0)
56
+ rubocop-ast (>= 1.28.0, < 2.0)
57
+ ruby-progressbar (~> 1.7)
58
+ unicode-display_width (>= 2.4.0, < 3.0)
59
+ rubocop-ast (1.28.1)
60
+ parser (>= 3.2.1.0)
61
+ rubocop-capybara (2.18.0)
62
+ rubocop (~> 1.41)
63
+ rubocop-factory_bot (2.23.1)
64
+ rubocop (~> 1.33)
65
+ rubocop-rspec (2.22.0)
66
+ rubocop (~> 1.33)
67
+ rubocop-capybara (~> 2.17)
68
+ rubocop-factory_bot (~> 2.22)
69
+ ruby-progressbar (1.13.0)
70
+ ruby2_keywords (0.0.5)
71
+ unicode-display_width (2.4.2)
72
+ vcr (6.1.0)
73
+ webmock (3.18.1)
74
+ addressable (>= 2.8.0)
75
+ crack (>= 0.3.2)
76
+ hashdiff (>= 0.4.0, < 2.0.0)
77
+
78
+ PLATFORMS
79
+ aarch64-linux
80
+ arm64-darwin-21
81
+ x86_64-darwin-22
82
+
83
+ DEPENDENCIES
84
+ anchor-pki!
85
+ minitest (~> 5.14)
86
+ rake (~> 13.0)
87
+ rspec (~> 3.9)
88
+ rubocop (~> 1.50)
89
+ rubocop-rspec (~> 2.22)
90
+ vcr (~> 6.1)
91
+ webmock (~> 3.8)
92
+
93
+ BUNDLED WITH
94
+ 2.3.26
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2023 Anchor Security, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # Anchor
2
+
3
+ Ruby client for Anchor PKI. See https://anchor.dev/ for details.
4
+
5
+ ## Configuration
6
+
7
+ The Following environment variables are available to configure the default
8
+ [`AutoCert::Manager`](./lib/anchor/auto_cert/manager.rb).
9
+
10
+ * `HTTPS_PORT` - the TCP numerical port to bind SSL to.
11
+ * `ACME_ALLOW_IDENTIFIERS` - A comma separated list of hostnames for provisioning certs
12
+ * `ACME_DIRECTORY_URL` - the ACME provider's directory
13
+ * `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
+ * `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
+ * `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
+ * `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)
18
+ * `AUTO_CERT_NAME` - **optional** the name to use to lookup the default `AutoCert::Configuration` in the `AutoCert::Registry`. This is `default` by default
19
+
20
+ If both `ACME_RENEW_BEFORE_SECONDS` and `ACME_RENEW_BEFORE_FRACTION` are set,
21
+ the one that causes the renewal to take place earlier is used.
22
+
23
+ Example:
24
+
25
+ * Cert start (not_before) moment is : `2023-05-24 20:53:11 UTC`
26
+ * Cert expiration (not_after) moment is : `2023-06-21 20:53:10 UTC`
27
+ * `ACME_RENEW_BEFORE_SECONDS` is `1209600` (14 days)
28
+ * `ACME_RENEW_BEFORE_FRACTION` is `0.25` - which equates to a before seconds value of `604799` (~7 days)
29
+
30
+ The possible moments to start renewing are:
31
+
32
+ * 14 days before expiration moment - `2023-06-07 20:53:10 UTC`
33
+ * when 25% of the valid time is left - `2023-06-14 20:53:11 UTC`
34
+
35
+ Currently the `AutoCert::Manager` will use whichever is earlier.
36
+
37
+ ### Example configuration
38
+
39
+ ```sh
40
+ HTTPS_PORT=44300
41
+ ACME_ALLOW_IDENTIFIERS=my.lcl.host,*.my.lcl.host
42
+ ACME_DIRECTORY_URL=https://acme-v02.api.letsencrypt.org/directory
43
+ ACME_KID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
44
+ ACME_HMAC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
45
+ ```
46
+
47
+ ## License
48
+
49
+ The gem is available as open source under the terms of the [MIT
50
+ License](./LICENSE.txt)
data/Rakefile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'minitest/test_task'
9
+ Minitest::TestTask.create(:test)
10
+
11
+ task default: %i[spec test]
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ # AutoCert Configuration provides a way to configure the AutoCert Manager.
6
+ #
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
10
+
11
+ # Note - although it is possible to set change the name of a config, it is
12
+ # not recommended. The name is used as the key in the Registry, and if a
13
+ # Configuration is in the Registry, and its name is changed, it does not
14
+ # change its registry key.
15
+ attr_accessor :name,
16
+ :allow_identifiers,
17
+ :cache,
18
+ :contact,
19
+ :directory_url,
20
+ :external_account_binding,
21
+ :renew_before_fraction,
22
+ :renew_before_seconds,
23
+ :tos_acceptors,
24
+ :work_dir
25
+
26
+ # rubocop:disable Metrics/ParameterLists
27
+ # Data defined classes have all required parameters in the initializer, so
28
+ # override the default initializer to allow for optional parameters and
29
+ # to pull in the defaults form the environment
30
+ #
31
+ def initialize(name:,
32
+ allow_identifiers: nil,
33
+ cache: nil,
34
+ contact: nil,
35
+ directory_url: nil,
36
+ external_account_binding: nil,
37
+ renew_before_fraction: DEFAULT_BEFORE_FRACTION,
38
+ renew_before_seconds: DEFAULT_BEFORE_SECONDS,
39
+ tos_acceptors: nil,
40
+ work_dir: nil)
41
+
42
+ @name = name
43
+
44
+ @allow_identifiers = allow_identifiers
45
+ @cache = cache
46
+ @contact = contact
47
+ @directory_url = directory_url
48
+ @external_account_binding = external_account_binding
49
+ @renew_before_fraction = renew_before_fraction
50
+ @renew_before_seconds = renew_before_seconds
51
+ @tos_acceptors = tos_acceptors
52
+ @work_dir = work_dir
53
+ end
54
+ # rubocop:enable Metrics/ParameterLists
55
+
56
+ def account
57
+ {
58
+ contact: contact,
59
+ external_account_binding: external_account_binding
60
+ }
61
+ end
62
+
63
+ # Enabled just means that the configuration is valid
64
+ def enabled?
65
+ validate!
66
+ true
67
+ rescue ConfigurationError => _e
68
+ false
69
+ end
70
+
71
+ def validate!
72
+ @allow_identifiers = prepare_allow_identifiers(@allow_identifiers)
73
+ @cache = prepare_cache(@cache)
74
+ @directory_url = prepare_directory_url(@directory_url)
75
+ @external_account_binding = prepare_external_account_binding(@external_account_binding)
76
+ @renew_before_fraction = prepare_renew_before_fraction(@renew_before_fraction)
77
+ @renew_before_seconds = prepare_renew_before_seconds(@renew_before_seconds)
78
+ @tos_acceptors = prepare_tos_acceptors(@tos_acceptors)
79
+ @work_dir = prepare_work_dir(@work_dir)
80
+
81
+ self
82
+ end
83
+
84
+ private
85
+
86
+ def prepare_allow_identifiers(allow_identifiers)
87
+ prepared = case allow_identifiers
88
+ when Array
89
+ allow_identifiers
90
+ when String
91
+ allow_identifiers.split(',')
92
+ when nil
93
+ ENV.fetch('ACME_ALLOW_IDENTIFIERS', nil)&.split(',')
94
+ end
95
+
96
+ if prepared.nil? || prepared.empty?
97
+ 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.'
101
+ end
102
+
103
+ prepared
104
+ end
105
+
106
+ def prepare_cache(cache)
107
+ return nil if cache.nil?
108
+
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
114
+
115
+ cache
116
+ end
117
+
118
+ def prepare_directory_url(directory_url)
119
+ 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
125
+
126
+ directory_url
127
+ end
128
+
129
+ def prepare_external_account_binding(external_account_binding)
130
+ kid = ENV.fetch('ACME_KID', nil)
131
+ hmac_key = ENV.fetch('ACME_HMAC_KEY', nil)
132
+
133
+ if kid && hmac_key
134
+ external_account_binding = {
135
+ kid: kid,
136
+ hmac_key: hmac_key
137
+ }
138
+ end
139
+ external_account_binding
140
+ end
141
+
142
+ 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
153
+ end
154
+
155
+ 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
164
+ end
165
+ renew_before_fraction
166
+ end
167
+
168
+ def prepare_tos_acceptors(tos_acceptors)
169
+ tos_acceptors = Array(tos_acceptors)
170
+
171
+ if tos_acceptors.empty? || tos_acceptors.any? { |tos| !tos.respond_to?(:accept?) }
172
+ 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?`.'
175
+ end
176
+
177
+ tos_acceptors
178
+ end
179
+
180
+ def prepare_work_dir(work_dir)
181
+ return nil if work_dir.nil?
182
+
183
+ work_dir = Pathname.new(work_dir) unless work_dir.is_a?(Pathname)
184
+
185
+ begin
186
+ work_dir.mkpath
187
+ rescue StandardError => e
188
+ raise ConfigurationError, "#{self.class}#work_dir : #{e.message}"
189
+ end
190
+
191
+ unless work_dir.directory? && work_dir.writable?
192
+ raise ConfigurationError, "#{self.class}#work_dir '#{work_dir}' must be a writable directory."
193
+ end
194
+
195
+ work_dir
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ #
6
+ # IdentifierPolicy is a class used to check that the identifiers used in
7
+ # certs would be valid.
8
+ #
9
+ # Each IdentierPolicy is initialized with a 'policy_description' which is used to
10
+ # derive the policy check.
11
+ #
12
+ # Current Policy Checks are:
13
+ # - ForHostname - checks that the identifier matches hostname exactly
14
+ # - ForWildcardHostname - checks that the identifier matches hostname with a wildcard prefix
15
+ # - ForIpAddress - checks that the identifier matches an IP address or subnet
16
+ #
17
+ class IdentifierPolicy
18
+ attr_reader :description, :check
19
+
20
+ # Given an individual, or an array of IdentifierPolicy or Strings build
21
+ # IdentifierPolicy objects
22
+ def self.build(policy_descriptions)
23
+ Array(policy_descriptions).map do |description|
24
+ IdentifierPolicy.new(description)
25
+ end
26
+ end
27
+
28
+ def self.new(description)
29
+ return description if description.is_a?(IdentifierPolicy)
30
+
31
+ super
32
+ end
33
+
34
+ # The list of policy checks that are available, the ordering here is
35
+ # important as the first one that matches is the one that is used for the
36
+ # check. So if a policy description would be matched by multiple checks,
37
+ # the one that it should match should be first.
38
+ def self.policy_checks
39
+ @policy_checks ||= [
40
+ PolicyCheck::ForIPAddr,
41
+ PolicyCheck::ForHostname,
42
+ PolicyCheck::ForWildcardHostname
43
+ ]
44
+ end
45
+
46
+ def initialize(description)
47
+ check_klass = self.class.policy_checks.find do |klass|
48
+ klass.handles?(description)
49
+ end
50
+ raise UnknownPolicyCheckError, "Unable to create a policy check based upon '#{description}'" if check_klass.nil?
51
+
52
+ @description = description
53
+ @check = check_klass.new(description)
54
+ end
55
+
56
+ def allow?(identifier)
57
+ raise ArgumentError, 'identifier must be a String' unless identifier.is_a?(String)
58
+
59
+ @check.allow?(identifier)
60
+ end
61
+
62
+ def deny?(identifier)
63
+ !allow?(identifier)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ require_relative 'policy_check'
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'forwardable'
4
+
5
+ module Anchor
6
+ module AutoCert
7
+ # ManagedCertificate is a class that represents a certificate and its manager
8
+ # for renewal
9
+ class ManagedCertificate
10
+ attr_reader :cert_pem, :cert_path, :key_pem, :key_path, :key, :manager, :x509
11
+
12
+ extend Forwardable
13
+ def_delegators :@manager, :enabled?
14
+ def_delegators :@x509, :not_after, :not_before, :serial
15
+
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)
20
+ end
21
+
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
33
+ end
34
+
35
+ def hex_serial(joiner = ':')
36
+ x509.serial.to_s(16).scan(/.{2}/).join(joiner)
37
+ end
38
+
39
+ def needs_renewal?(now = Time.now.utc)
40
+ manager.needs_renewal?(cert: x509, now: now)
41
+ end
42
+
43
+ def identifiers
44
+ alt_names = x509&.extensions&.find { |ext| ext.oid == 'subjectAltName' }&.value&.split(', ') || []
45
+ alt_names.map { |name| name.sub(/^DNS:/, '') }
46
+ end
47
+
48
+ def common_name
49
+ x509.subject.to_a.find { |name, _, _| name == 'CN' }[1]
50
+ end
51
+
52
+ def write_working_files
53
+ cert_path.open('w') { |f| f << cert_pem }
54
+ key_path.open('w') { |f| f << key_pem }
55
+ end
56
+
57
+ def purge_working_files
58
+ cert_path.delete if cert_path.exist?
59
+ key_path.delete if key_path.exist?
60
+ end
61
+ end
62
+ end
63
+ end