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 +4 -4
- data/CHANGELOG.md +22 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +94 -0
- data/LICENSE.txt +21 -0
- data/README.md +50 -0
- data/Rakefile +11 -0
- data/lib/anchor/auto_cert/configuration.rb +199 -0
- data/lib/anchor/auto_cert/identifier_policy.rb +68 -0
- data/lib/anchor/auto_cert/managed_certificate.rb +63 -0
- data/lib/anchor/auto_cert/manager.rb +200 -0
- data/lib/anchor/auto_cert/policy_check/for_hostname.rb +40 -0
- data/lib/anchor/auto_cert/policy_check/for_ipaddr.rb +48 -0
- data/lib/anchor/auto_cert/policy_check/for_wildcard_hostname.rb +57 -0
- data/lib/anchor/auto_cert/policy_check.rb +37 -0
- data/lib/anchor/auto_cert/railtie.rb +88 -0
- data/lib/anchor/auto_cert/registry.rb +63 -0
- data/lib/anchor/auto_cert/renewal_busy_wait.rb +39 -0
- data/lib/anchor/auto_cert/terms_of_service_acceptor.rb +34 -0
- data/lib/anchor/auto_cert.rb +21 -0
- data/lib/anchor/version.rb +5 -0
- data/lib/anchor-pki.rb +6 -14
- data/lib/anchor.rb +23 -0
- data/lib/puma/dsl.rb +28 -0
- data/lib/puma/plugin/auto_cert.rb +97 -0
- metadata +134 -9
- data/lib/anchor-pki/auto_cert.rb +0 -134
@@ -0,0 +1,200 @@
|
|
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
|
+
:work_dir
|
31
|
+
|
32
|
+
def self.for(configuration)
|
33
|
+
new(configuration: configuration)
|
34
|
+
end
|
35
|
+
|
36
|
+
def initialize(configuration:, client: nil)
|
37
|
+
configuration.validate!
|
38
|
+
@configuration = configuration
|
39
|
+
|
40
|
+
@identifier_policies = IdentifierPolicy.build(@configuration.allow_identifiers)
|
41
|
+
@tos_acceptors = Array(@configuration.tos_acceptors)
|
42
|
+
@directory_url = URI.parse(@configuration.directory_url)
|
43
|
+
@work_dir = Pathname.new(@configuration.work_dir || Dir.mktmpdir)
|
44
|
+
|
45
|
+
@account_opts = {
|
46
|
+
contact: @configuration.contact,
|
47
|
+
external_account_binding: @configuration.external_account_binding
|
48
|
+
}
|
49
|
+
|
50
|
+
@client = client || new_client(contact: @configuration.contact)
|
51
|
+
@enabled = true
|
52
|
+
end
|
53
|
+
|
54
|
+
# It is currently assumed that the common name is the first of the
|
55
|
+
# `identifiers` passed into this method. If that is not the case, then
|
56
|
+
# 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(',')}'"
|
60
|
+
end
|
61
|
+
|
62
|
+
key_pem = cache&.read("#{common_name}+#{algorithm}")
|
63
|
+
cert_pem = cache&.read(common_name)
|
64
|
+
|
65
|
+
if key_pem.nil? || cert_pem.nil?
|
66
|
+
cert_pem, key_pem = provision(identifiers: identifiers, algorithm: algorithm, common_name: common_name,
|
67
|
+
**opts)
|
68
|
+
|
69
|
+
cache&.write("#{common_name}+#{algorithm}", key_pem)
|
70
|
+
cache&.write(common_name, cert_pem)
|
71
|
+
end
|
72
|
+
|
73
|
+
ManagedCertificate.new(manager: self, cert_pem: cert_pem, key_pem: key_pem)
|
74
|
+
end
|
75
|
+
|
76
|
+
def needs_renewal?(cert:, now: Time.now.utc)
|
77
|
+
renew_after = [
|
78
|
+
renew_after_from_seconds(cert: cert),
|
79
|
+
renew_after_from_fraction(cert: cert),
|
80
|
+
renew_after_fallback(cert: cert),
|
81
|
+
cert.not_after # cert expired, get a new one
|
82
|
+
].compact.min
|
83
|
+
|
84
|
+
(now > renew_after)
|
85
|
+
end
|
86
|
+
|
87
|
+
def disable
|
88
|
+
@enabled = false
|
89
|
+
end
|
90
|
+
|
91
|
+
# if the manager is enabled && the configuration is enabled
|
92
|
+
def enabled?
|
93
|
+
@enabled && configuration.enabled?
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
|
98
|
+
def provision(identifiers:, algorithm:, common_name:, **opts)
|
99
|
+
identifiers = Array(identifiers)
|
100
|
+
load_or_build_account
|
101
|
+
key_pem ||= new_key(algorithm).to_pem
|
102
|
+
csr = Acme::Client::CertificateRequest.new(private_key: parse_key_pem(key_pem), common_name: common_name,
|
103
|
+
names: identifiers)
|
104
|
+
|
105
|
+
order = @client.new_order(identifiers: identifiers, **opts)
|
106
|
+
order.finalize(csr: csr)
|
107
|
+
# TODO: loop over order.authorizations and process the challenges
|
108
|
+
|
109
|
+
while order.status == 'processing'
|
110
|
+
sleep(1)
|
111
|
+
order.reload
|
112
|
+
end
|
113
|
+
|
114
|
+
[order.certificate, key_pem]
|
115
|
+
end
|
116
|
+
|
117
|
+
def account
|
118
|
+
@account ||= build_account(**@account_opts)
|
119
|
+
end
|
120
|
+
alias load_or_build_account account
|
121
|
+
|
122
|
+
def build_account(contact: nil, external_account_binding: nil, terms_of_service_agreed: false, **)
|
123
|
+
terms_of_service_agreed ||= @tos_acceptors.any? { |a| a.accept?(@client.terms_of_service) }
|
124
|
+
|
125
|
+
@client.new_account(contact: contact, terms_of_service_agreed: terms_of_service_agreed,
|
126
|
+
external_account_binding: external_account_binding)
|
127
|
+
end
|
128
|
+
|
129
|
+
def renew_after_from_seconds(cert:, before_seconds: renew_before_seconds)
|
130
|
+
return nil unless before_seconds
|
131
|
+
|
132
|
+
renew_after = (cert.not_after - before_seconds)
|
133
|
+
|
134
|
+
# invalid if the renewal time is outside the certificate validity window
|
135
|
+
return nil unless (cert.not_before..cert.not_after).cover?(renew_after)
|
136
|
+
|
137
|
+
renew_after
|
138
|
+
end
|
139
|
+
|
140
|
+
def renew_after_from_fraction(cert:, before_fraction: renew_before_fraction)
|
141
|
+
return nil unless (0..1).cover?(before_fraction)
|
142
|
+
|
143
|
+
valid_span = cert.not_after - cert.not_before
|
144
|
+
before_seconds = (valid_span * before_fraction).floor
|
145
|
+
|
146
|
+
renew_after_from_seconds(cert: cert, before_seconds: before_seconds)
|
147
|
+
end
|
148
|
+
|
149
|
+
# Fallback timestamp, in case there is some corner case that is not covered
|
150
|
+
def renew_after_fallback(cert:)
|
151
|
+
renew_after_from_seconds(cert: cert, before_seconds: FALLBACK_RENEW_BEFORE_SECONDS)
|
152
|
+
end
|
153
|
+
|
154
|
+
def new_client(account_key: nil, contact: nil, **)
|
155
|
+
account_key ||= fetch_account_key(contact) { new_key(:ecdsa) }
|
156
|
+
|
157
|
+
Acme::Client.new(private_key: account_key, directory: @directory_url)
|
158
|
+
end
|
159
|
+
|
160
|
+
def new_key(algorithm)
|
161
|
+
case algorithm
|
162
|
+
when :ecdsa then OpenSSL::PKey::EC.generate('prime256v1')
|
163
|
+
else
|
164
|
+
raise UnknownAlgorithmError, "unknown key algorithm '#{algorithm}'"
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
def fetch_account_key(contact)
|
169
|
+
id = "#{contact || 'default'}+#{@directory_url.host}+key"
|
170
|
+
parse_key_pem(cache&.fetch(id) { parse_key_pem(yield.to_pem) } || yield.to_pem)
|
171
|
+
end
|
172
|
+
|
173
|
+
def parse_key_pem(data)
|
174
|
+
key_pem = parse_rsa_pem(data) || parse_ecdsa_pem(data)
|
175
|
+
raise UnknownKeyFormatError unless key_pem
|
176
|
+
|
177
|
+
key_pem
|
178
|
+
end
|
179
|
+
|
180
|
+
def parse_rsa_pem(data)
|
181
|
+
OpenSSL::PKey::RSA.new(data)
|
182
|
+
rescue StandardError
|
183
|
+
nil
|
184
|
+
end
|
185
|
+
|
186
|
+
def parse_ecdsa_pem(data)
|
187
|
+
OpenSSL::PKey::EC.new(data)
|
188
|
+
rescue StandardError
|
189
|
+
nil
|
190
|
+
end
|
191
|
+
|
192
|
+
# returns all those identifiers that were not accepted by any policy
|
193
|
+
def denied_identifiers(identifiers)
|
194
|
+
Array(identifiers).reject do |identifier|
|
195
|
+
@identifier_policies.any? { |policy| policy.allow?(identifier) }
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
end
|
200
|
+
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,39 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Anchor
|
4
|
+
module AutoCert
|
5
|
+
# RenewalBusyWait is a class that will loop to check if a certificate needs to
|
6
|
+
# to be renewed, and if it does, return
|
7
|
+
#
|
8
|
+
# Generally this class should be used inside its own thread as it will sleep
|
9
|
+
# and loop until the pem file is able to be renewed.
|
10
|
+
#
|
11
|
+
# Every 'check_every' interval it will check if the certificate needs to be
|
12
|
+
# renewed. If it does, the loop will end and `wait_for_it` will return. If
|
13
|
+
# it does not need renewal, the block will be called, and if the result of
|
14
|
+
# the block is falsy, the loop will exit early
|
15
|
+
#
|
16
|
+
class RenewalBusyWait
|
17
|
+
ONE_HOUR = 60 * 60
|
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)
|
21
|
+
waiter.wait_for_it(&keep_going)
|
22
|
+
end
|
23
|
+
|
24
|
+
def initialize(managed_certificate:, check_every: ONE_HOUR)
|
25
|
+
@managed_certificate = managed_certificate
|
26
|
+
@check_every = check_every
|
27
|
+
end
|
28
|
+
|
29
|
+
def wait_for_it
|
30
|
+
loop do
|
31
|
+
break if @managed_certificate.needs_renewal?
|
32
|
+
break unless yield
|
33
|
+
|
34
|
+
sleep @check_every
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
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,21 @@
|
|
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/managed_certificate'
|
17
|
+
require_relative 'auto_cert/identifier_policy'
|
18
|
+
require_relative 'auto_cert/registry'
|
19
|
+
require_relative 'auto_cert/renewal_busy_wait'
|
20
|
+
|
21
|
+
require_relative 'auto_cert/railtie' if defined?(Rails::Railtie)
|
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
|
-
|
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
|
-
|
6
|
-
|
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'
|