anchor-pki 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'acme/client'
4
+ require 'uri'
5
+ require 'tmpdir'
6
+ require 'pathname'
7
+ require 'forwardable'
8
+
9
+ module Anchor
10
+ module AutoCert
11
+ # AutoCert Manager provides automatic access to certificates from Anchor, but
12
+ # theoretically it could work with Let's Encrypt or any other ACME-based CA.
13
+ class Manager
14
+ FALLBACK_RENEW_BEFORE_SECONDS = 24 * 60 * 60 # 1 day
15
+
16
+ extend Forwardable
17
+ def_delegators :@configuration,
18
+ :cache,
19
+ :contact,
20
+ :directory,
21
+ :external_account_binding,
22
+ :renew_before_fraction,
23
+ :renew_before_seconds
24
+
25
+ attr_reader :client,
26
+ :configuration,
27
+ :directory_url,
28
+ :identifier_policies,
29
+ :tos_acceptors
30
+
31
+ def self.for(configuration)
32
+ new(configuration: configuration)
33
+ end
34
+
35
+ def initialize(configuration:, client: nil)
36
+ configuration.validate!
37
+ @configuration = configuration
38
+
39
+ @identifier_policies = IdentifierPolicy.build(@configuration.allow_identifiers)
40
+ @tos_acceptors = Array(@configuration.tos_acceptors)
41
+ @directory_url = URI.parse(@configuration.directory_url)
42
+ @work_dir = Pathname.new(@configuration.work_dir || Dir.mktmpdir)
43
+
44
+ @account_opts = {
45
+ contact: @configuration.contact,
46
+ external_account_binding: @configuration.external_account_binding
47
+ }
48
+
49
+ @client = client || new_client(contact: @configuration.contact)
50
+ @enabled = true
51
+ end
52
+
53
+ # It is currently assumed that the common name is the first of the
54
+ # `identifiers` passed into this method. If that is not the case, then the
55
+ # `common_name` parameter needs to be set explicitly
56
+ def certificate_paths(identifiers:, algorithm: :ecdsa, common_name: Array(identifiers).first, **opts)
57
+ cert_pem, key_pem = certificate(identifiers: identifiers, algorithm: algorithm, common_name: common_name,
58
+ **opts)
59
+
60
+ cert = (@work_dir / common_name).open('w') { |f| f << cert_pem }.path
61
+ key = (@work_dir / "#{common_name}+#{algorithm}").open('w') { |f| f << key_pem }.path
62
+
63
+ [cert, key]
64
+ end
65
+
66
+ # It is currently assumed that the common name is the first of the
67
+ # `identifiers` passed into this method. If that is not the case, then
68
+ # the `common_name` parameter needs to be set explicitly
69
+ def certificate(identifiers:, algorithm: :ecdsa, common_name: Array(identifiers).first, **opts)
70
+ if (arr = denied_identifiers(identifiers)).size.positive?
71
+ raise IdentifierNotAllowedError, "denied identifiers'#{arr.join(',')}'"
72
+ end
73
+
74
+ key_pem = cache&.read("#{common_name}+#{algorithm}")
75
+ cert_pem = cache&.read(common_name)
76
+
77
+ if key_pem.nil? || cert_pem.nil? || needs_renewal?(cert_pem: cert_pem)
78
+ cert_pem, key_pem = provision(identifiers: identifiers, algorithm: algorithm, common_name: common_name,
79
+ **opts)
80
+
81
+ cache&.write("#{common_name}+#{algorithm}", key_pem)
82
+ cache&.write(common_name, cert_pem)
83
+ end
84
+
85
+ [cert_pem, key_pem]
86
+ end
87
+
88
+ def disable
89
+ @enabled = false
90
+ end
91
+
92
+ # Currently this only tests for kid/hmac_key, but in the future it could
93
+ # test for other configuration options.
94
+ def enabled?
95
+ @enabled && configuration.enabled?
96
+ end
97
+
98
+ private
99
+
100
+ def provision(identifiers:, algorithm:, common_name:, **opts)
101
+ identifiers = Array(identifiers)
102
+ load_or_build_account
103
+ 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)
106
+
107
+ order = @client.new_order(identifiers: identifiers, **opts)
108
+ order.finalize(csr: csr)
109
+ # TODO: loop over order.authorizations and process the challenges
110
+
111
+ while order.status == 'processing'
112
+ sleep(1)
113
+ order.reload
114
+ end
115
+
116
+ [order.certificate, key_pem]
117
+ end
118
+
119
+ def account
120
+ @account ||= build_account(**@account_opts)
121
+ end
122
+ alias load_or_build_account account
123
+
124
+ def build_account(contact: nil, external_account_binding: nil, terms_of_service_agreed: false, **)
125
+ terms_of_service_agreed ||= @tos_acceptors.any? { |a| a.accept?(@client.terms_of_service) }
126
+
127
+ @client.new_account(contact: contact, terms_of_service_agreed: terms_of_service_agreed,
128
+ external_account_binding: external_account_binding)
129
+ end
130
+
131
+ def needs_renewal?(cert_pem:, now: Time.now.utc)
132
+ cert = OpenSSL::X509::Certificate.new(cert_pem)
133
+
134
+ renew_after = [
135
+ renew_after_from_seconds(cert: cert),
136
+ renew_after_from_fraction(cert: cert),
137
+ renew_after_fallback(cert: cert),
138
+ cert.not_after # cert expired, get a new one
139
+ ].compact.min
140
+
141
+ (now > renew_after)
142
+ end
143
+
144
+ def renew_after_from_seconds(cert:, before_seconds: renew_before_seconds)
145
+ return nil unless before_seconds
146
+
147
+ renew_after = (cert.not_after - before_seconds)
148
+
149
+ # invalid if the renewal time is outside the certificate validity window
150
+ return nil unless (cert.not_before..cert.not_after).cover?(renew_after)
151
+
152
+ renew_after
153
+ end
154
+
155
+ def renew_after_from_fraction(cert:, before_fraction: renew_before_fraction)
156
+ return nil unless (0..1).cover?(before_fraction)
157
+
158
+ valid_span = cert.not_after - cert.not_before
159
+ before_seconds = (valid_span * before_fraction).floor
160
+
161
+ renew_after_from_seconds(cert: cert, before_seconds: before_seconds)
162
+ end
163
+
164
+ # Fallback timestamp, in case there is some corner case that is not covered
165
+ def renew_after_fallback(cert:)
166
+ renew_after_from_seconds(cert: cert, before_seconds: FALLBACK_RENEW_BEFORE_SECONDS)
167
+ end
168
+
169
+ def new_client(account_key: nil, contact: nil, **)
170
+ account_key ||= fetch_account_key(contact) { new_key(:ecdsa) }
171
+
172
+ Acme::Client.new(private_key: account_key, directory: @directory_url)
173
+ end
174
+
175
+ def new_key(algorithm)
176
+ case algorithm
177
+ when :ecdsa then OpenSSL::PKey::EC.generate('prime256v1')
178
+ else
179
+ raise UnknownAlgorithmError, "unknown key algorithm '#{algorithm}'"
180
+ end
181
+ end
182
+
183
+ def fetch_account_key(contact)
184
+ id = "#{contact || 'default'}+#{@directory_url.host}+key"
185
+ parse_key_pem(cache&.fetch(id) { parse_key_pem(yield.to_pem) } || yield.to_pem)
186
+ end
187
+
188
+ def parse_key_pem(data)
189
+ key_pem = parse_rsa_pem(data) || parse_ecdsa_pem(data)
190
+ raise UnknownKeyFormatError unless key_pem
191
+
192
+ key_pem
193
+ end
194
+
195
+ def parse_rsa_pem(data)
196
+ OpenSSL::PKey::RSA.new(data)
197
+ rescue StandardError
198
+ nil
199
+ end
200
+
201
+ def parse_ecdsa_pem(data)
202
+ OpenSSL::PKey::EC.new(data)
203
+ rescue StandardError
204
+ nil
205
+ end
206
+
207
+ # returns all those identifiers that were not accepted by any policy
208
+ def denied_identifiers(identifiers)
209
+ Array(identifiers).reject do |identifier|
210
+ @identifier_policies.any? { |policy| policy.allow?(identifier) }
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ class PolicyCheck
6
+ # Check the identifier by strict hostname name comparison
7
+ #
8
+ # Reference: http://www.faqs.org/rfcs/rfc2396.html
9
+ #
10
+ class ForHostname < PolicyCheck
11
+ ALPHA = '[a-zA-Z]'
12
+ ALPHA_NUMERIC = '[a-zA-Z0-9]'
13
+ ALPHA_NUMERIC_HYPHEN = '[-a-zA-Z0-9]'
14
+ DOMAIN_LABEL = "#{ALPHA_NUMERIC}#{ALPHA_NUMERIC_HYPHEN}*#{ALPHA_NUMERIC}"
15
+ TOP_LEVEL_DOMAIN = "#{ALPHA}#{ALPHA_NUMERIC_HYPHEN}*#{ALPHA_NUMERIC}"
16
+
17
+ REGEX = /
18
+ \A
19
+ (?<sub>(#{DOMAIN_LABEL}\.)+)
20
+ (?<tld>#{TOP_LEVEL_DOMAIN})
21
+ \z
22
+ /ix.freeze
23
+
24
+ def self.handles?(description)
25
+ description.is_a?(String) && description.match?(REGEX)
26
+ end
27
+
28
+ def initialize(description)
29
+ super
30
+ @hostname = description.downcase
31
+ end
32
+
33
+ # case insensitive comparison
34
+ def allow?(name)
35
+ name.is_a?(String) && (name.downcase == @hostname)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ module Anchor
5
+ module AutoCert
6
+ class PolicyCheck
7
+ #
8
+ # A PolicyCheck that compares the given IP address to an ipaddress
9
+ # or subnet
10
+ #
11
+ # The description for an IPAddr policy check can be anything that is
12
+ # parsed by the ruby IPAddr.new() method. Generally this is something in
13
+ # the format an ipv4 address, an ipv6 address, or CIDR notation.
14
+ #
15
+ # - "192.168.42.1"
16
+ # - "192.168.42.0/24"
17
+ # - "3ffe:505:2::1"
18
+ # - "2001:db8::/32"
19
+ #
20
+ class ForIPAddr < PolicyCheck
21
+ def self.handles?(description)
22
+ return true if description.is_a?(IPAddr)
23
+
24
+ begin
25
+ IPAddr.new(description)
26
+ true
27
+ rescue IPAddr::Error
28
+ false
29
+ end
30
+ end
31
+
32
+ def initialize(description)
33
+ super
34
+
35
+ @ipaddr = if description.is_a?(IPAddr)
36
+ description
37
+ else
38
+ IPAddr.new(description)
39
+ end
40
+ end
41
+
42
+ def allow?(name)
43
+ @ipaddr.include?(name)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ class PolicyCheck
6
+ # Check that the identifier is allowed by wildcard hostname matching
7
+ #
8
+ # This does the same as the ForHostname check, but allows for an arbitrary
9
+ # prefixes, although the prefix itself also needs to match appropriate
10
+ # domain name rules.
11
+ #
12
+ # The description for a wildcard hostname check MUST be a string that starts
13
+ # with `*.` followed by a valid domainname (which matches the ForHostname
14
+ # check)
15
+ #
16
+ #
17
+ class ForWildcardHostname < PolicyCheck
18
+ DOMAIN_LABEL_REGEX = /\A#{ForHostname::DOMAIN_LABEL}*\z/i.freeze
19
+ SPLAT = '*'
20
+
21
+ def self.handles?(description)
22
+ return false unless description.is_a?(String)
23
+
24
+ parts = description.split('.')
25
+ return false unless parts[0] == SPLAT
26
+
27
+ suffix = parts[1..-1].join('.')
28
+ # reuse the hostname check here
29
+ ForHostname.handles?(suffix)
30
+ end
31
+
32
+ def initialize(description)
33
+ super
34
+ @parts = description.split('.')
35
+ @wildcard = @parts.shift # assumed SPLAT
36
+ @suffix = @parts.join('.').downcase
37
+ end
38
+
39
+ # An explicit '*.rest.of' is an allowable hostname for this check, even
40
+ # though it is not a valid domain name
41
+ #
42
+ def allow?(hostname)
43
+ return false unless hostname.is_a?(String)
44
+
45
+ parts = hostname.split('.')
46
+ prefix = parts.shift
47
+
48
+ return false unless (prefix == SPLAT) || DOMAIN_LABEL_REGEX.match?(prefix)
49
+
50
+ domain = parts.join('.').downcase
51
+
52
+ (domain == @suffix)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ # Base class for PolicyCheck classes, these are utility classes used by the
6
+ # IdentifierPolicy class.
7
+ #
8
+ # The .handles? method is used to determine if an instance of this class
9
+ # could be created from the given description.
10
+ #
11
+ # Then the #allow? method is used to determine if the given identifier is allowed
12
+ # and the #deny? method is used to determine if the given identifier is denied.
13
+ #
14
+ class PolicyCheck
15
+ def self.handles?(description)
16
+ raise NotImplementedError, "#{self.class} must implement .handles?(description)"
17
+ end
18
+
19
+ attr_reader :policy_description
20
+
21
+ def initialize(description)
22
+ @policy_description = description
23
+ end
24
+
25
+ def deny?(identifier)
26
+ !allow?(identifier)
27
+ end
28
+
29
+ def allow?(identifier)
30
+ raise NotImplementedError, "#{self.class} must implement #allow?(identifier)"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ require_relative 'policy_check/for_ipaddr'
36
+ require_relative 'policy_check/for_hostname'
37
+ require_relative 'policy_check/for_wildcard_hostname'
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ # AutoCert Railtie
6
+ class Railtie < Rails::Railtie
7
+ # Initialize the configuration with a blank configuration, ensuring
8
+ # the configuration exists, even if it is not used.
9
+ config.auto_cert = ::Anchor::AutoCert::Configuration.new(name: :rails)
10
+
11
+ # Make sure the auto cert configuration is valid before the app boots
12
+ # This will run after every code reload in development and after boot in
13
+ # production
14
+ config.to_prepare do
15
+ if Rails.configuration.auto_cert.enabled?
16
+ Rails.configuration.auto_cert.validate!
17
+
18
+ # register the configuration under its name so that it can
19
+ # be discovered by other parts of the application
20
+ auto_cert_config = Rails.configuration.auto_cert
21
+ unless ::Anchor::AutoCert::Registry.key?(auto_cert_config.name)
22
+ ::Anchor::AutoCert::Registry.store(auto_cert_config.name, auto_cert_config)
23
+ end
24
+ end
25
+ end
26
+
27
+ # this needs to be after the load_config_initializers so that the
28
+ # application can override the :rails auto_cert configuration
29
+ #
30
+ initializer 'auto_cert.configure_rails_initialization', after: :load_config_initializers do |app|
31
+ auto_cert_config = Railtie.determine_configuration(app)
32
+ app.config.auto_cert = auto_cert_config
33
+
34
+ # Update the app.config.hosts with the allow_identifiers if we are NOT
35
+ # in the test environment.
36
+ #
37
+ # In the test environment `config.hosts` is normally empty, and as a
38
+ # result HostAuthorization is not used. If we add the allow_identifiers
39
+ # to the `config.hosts` then HostAuthorization will be used, and tests
40
+ # will break.
41
+ unless Rails.env.test?
42
+ auto_cert_config&.allow_identifiers&.each do |identifier|
43
+ # need to convert an identifier into a host matcher, which is just
44
+ # strip off a leading '*' if it exists so that all subdomains match.
45
+ #
46
+ # https://guides.rubyonrails.org/configuring.html#actiondispatch-hostauthorization
47
+ host = identifier.to_s.sub(/^\*/, '')
48
+ app.config.hosts << host
49
+ end
50
+ end
51
+ end
52
+
53
+ def self.determine_configuration(app)
54
+ auto_cert_config = app.config.auto_cert
55
+
56
+ # If no configuration is set, then try to lookup one under the :rails
57
+ # key or create a default one.
58
+ begin
59
+ auto_cert_config ||= ::Anchor::AutoCert::Registry.fetch(:rails)
60
+ rescue KeyError
61
+ auto_cert_config = Railtie.try_to_create_default_configuration
62
+ end
63
+
64
+ return nil unless auto_cert_config
65
+
66
+ # Set some reasonable defaults for a scratch locations if they are not
67
+ # set explicitly.
68
+ acme_scratch_dir = app.root / 'tmp' / 'acme'
69
+ acme_scratch_dir.mkpath
70
+ auto_cert_config.cache ||= ActiveSupport::Cache::FileStore.new(acme_scratch_dir / 'cache')
71
+ auto_cert_config.work_dir ||= (acme_scratch_dir / 'work')
72
+
73
+ auto_cert_config
74
+ end
75
+
76
+ def self.try_to_create_default_configuration
77
+ # If it doesn't exist, create a new one - now this may raise an error
78
+ # if the configuration is not setup correctly
79
+ ::Anchor::AutoCert::Configuration.new(name: :rails)
80
+ rescue ConfigurationError => e
81
+ # its fine to not have a coniguration, just log the error and move on
82
+ msg = "[AutoCert] Unable to create the :rails configuration : #{e.message}"
83
+ Rails.logger.error(msg)
84
+ nil
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ # A global registry for storing AutoCert::Configuration objects
6
+ class Registry
7
+ @_registry = {}
8
+ @_mutex = Mutex.new
9
+
10
+ def self.store(name, configuration)
11
+ @_mutex.synchronize do
12
+ @_registry.store(name.to_s, configuration)
13
+ end
14
+ end
15
+
16
+ def self.fetch(name)
17
+ @_mutex.synchronize do
18
+ @_registry.fetch(name.to_s)
19
+ end
20
+ end
21
+
22
+ def self.key?(name)
23
+ @_mutex.synchronize do
24
+ @_registry.key?(name.to_s)
25
+ end
26
+ end
27
+
28
+ def self.delete(name)
29
+ @_mutex.synchronize do
30
+ @_registry.delete(name.to_s)
31
+ end
32
+ end
33
+
34
+ # helpers for creating an AutoCert::Manager instance from registered
35
+ # configuration
36
+ def self.manager_for(name)
37
+ manager_for!(name)
38
+ rescue KeyError
39
+ nil
40
+ end
41
+
42
+ # helpers for creating an AutoCert::Manager instance from registered
43
+ # configuration - raises KeyError if the named configuration does not
44
+ # exist
45
+ def self.manager_for!(name)
46
+ configuration = fetch(name)
47
+ AutoCert::Manager.new(configuration: configuration)
48
+ end
49
+
50
+ def self.default_configuration
51
+ fetch(:default)
52
+ rescue KeyError
53
+ default = AutoCert::Configuration.new
54
+ store(:default, default)
55
+ default
56
+ end
57
+
58
+ def self.default_manager
59
+ manager_for(:default)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ # Any class that implements the following interface can be used as a
6
+ # Terms of Service Acceptor. The interface is the single method `#accept?`
7
+ # which is handed the terms of service URI as a String.
8
+ module TermsOfServiceAcceptor
9
+ def accept?(tos_uri)
10
+ raise NotImplementedError, "#{self.class} must implement #accept?(tos_uri)"
11
+ end
12
+
13
+ # Terms of Service Acceptor that will always match anything
14
+ class Any
15
+ include TermsOfServiceAcceptor
16
+ def accept?(_tos_uri)
17
+ true
18
+ end
19
+ end
20
+
21
+ # Terms of Service Acceptor that matches based upon a regular expression
22
+ class Regex
23
+ include TermsOfServiceAcceptor
24
+ def initialize(regex)
25
+ @regex = regex
26
+ end
27
+
28
+ def accept?(tos_uri)
29
+ @regex.match?(tos_uri)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ module AutoCert
5
+ class Error < StandardError; end
6
+ class IdentifierNotAllowedError < Error; end
7
+ class ConfigurationError < Error; end
8
+ class UnknownPolicyCheckError < Error; end
9
+ class UnknownAlgorithmError < Error; end
10
+ class UnknownKeyFormatError < Error; end
11
+ end
12
+ end
13
+ require_relative 'auto_cert/terms_of_service_acceptor'
14
+ require_relative 'auto_cert/configuration'
15
+ require_relative 'auto_cert/manager'
16
+ require_relative 'auto_cert/identifier_policy'
17
+ require_relative 'auto_cert/registry'
18
+ require_relative 'auto_cert/integration'
19
+
20
+ require_relative 'auto_cert/railtie' if defined?(Rails::Railtie)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ VERSION = '0.3.0'
5
+ end
data/lib/anchor-pki.rb CHANGED
@@ -1,17 +1,9 @@
1
+ # rubocop:disable Naming/FileName
2
+ #
1
3
  # frozen_string_literal: true
2
4
 
3
- require 'openssl'
5
+ # this file is named anchor-pki.rb to match the gem name and to be consistent
6
+ # with the other anchor modules for other languages
4
7
 
5
- module Anchor
6
- def self.add_cert(pem)
7
- (@certs ||= []) << OpenSSL::X509::Certificate.new(pem)
8
- end
9
-
10
- def self.cert_store
11
- @ssl_cert_store ||= OpenSSL::X509::Store.new.tap do |store|
12
- (@certs || []).each do |cert|
13
- store.add_cert(cert)
14
- end
15
- end
16
- end
17
- end
8
+ require_relative './anchor'
9
+ # rubocop:enable Naming/FileName
data/lib/anchor.rb ADDED
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ #
6
+ # Anchor module is the top-level namespace for the Anchor PKI client.
7
+ #
8
+ module Anchor
9
+ def self.add_cert(pem)
10
+ (@certs ||= []) << OpenSSL::X509::Certificate.new(pem)
11
+ end
12
+
13
+ def self.cert_store
14
+ @cert_store ||= OpenSSL::X509::Store.new.tap do |store|
15
+ (@certs || []).each do |cert|
16
+ store.add_cert(cert)
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ require_relative './anchor/version'
23
+ require_relative './anchor/auto_cert'