anchor-pki 0.3.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: 30508321e074e903d3e8328cc7b2e32fad03b696a32dbb2f16014e92f7636ede
4
- data.tar.gz: 7705acb22177288ac5de11a37c62080504efef41a82c7337673c84dadc90fd28
3
+ metadata.gz: 342dadd5c28835816da2c07cdd1d4b7b546cc2a35ca80738cc98707d2a048cd4
4
+ data.tar.gz: 97cb3d7c9c9bbb770c8a3a52eae0690bc7f18cadd5f3a31689d6d00239f5a523
5
5
  SHA512:
6
- metadata.gz: 392139d7eea3518ba1d9f9915e628050818e3855494b55876e8fc63ffa4aff45e6434918f8fabae4bd6fe042210ac56fc7b1e0fe6093ee558013b85cb0d15551
7
- data.tar.gz: e8f674f66082121873997074914a619205b9375608a6db2f1bd4afe6199d43c96f9874d3d584811f251143cd3c559f90b30ec5ca70ef1c42f01c043da4c3d5ef
6
+ metadata.gz: 06b0f93d4bc1962f60a4122bd2f4a046818e36c49779eae9255ff24d6cbb7ba9bd9029bb4f72f516b96c5fa8aef98cea7fbbd6f29ae3b69e42e6cb31e5990ade
7
+ data.tar.gz: f220a29a0c170f74b8926a1ae804f65d1719a4f9956edc04c6844ac27b2404f15c56908cf5b608a35fec1fe10679b74442ace2b3f6034af3eab814d564da670d
data/CHANGELOG.md CHANGED
@@ -1,6 +1,13 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.3.0] - 2023-06-02
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
4
11
 
5
12
  - improve gem packaging
6
13
  - extract out a configuration class for Acme
data/README.md CHANGED
@@ -14,6 +14,8 @@ The Following environment variables are available to configure the default
14
14
  * `ACME_HMAC_KEY` - your EAB HMAC_KEY for authenticating with the ACME directory above
15
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
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
17
19
 
18
20
  If both `ACME_RENEW_BEFORE_SECONDS` and `ACME_RENEW_BEFORE_FRACTION` are set,
19
21
  the one that causes the renewal to take place earlier is used.
@@ -60,10 +60,12 @@ module Anchor
60
60
  }
61
61
  end
62
62
 
63
- # Enabled means that the configuration has both allow_identifiers and a
64
- # directory url set.
63
+ # Enabled just means that the configuration is valid
65
64
  def enabled?
66
- allow_identifiers && directory_url
65
+ validate!
66
+ true
67
+ rescue ConfigurationError => _e
68
+ false
67
69
  end
68
70
 
69
71
  def validate!
@@ -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
@@ -26,7 +26,8 @@ module Anchor
26
26
  :configuration,
27
27
  :directory_url,
28
28
  :identifier_policies,
29
- :tos_acceptors
29
+ :tos_acceptors,
30
+ :work_dir
30
31
 
31
32
  def self.for(configuration)
32
33
  new(configuration: configuration)
@@ -50,23 +51,10 @@ module Anchor
50
51
  @enabled = true
51
52
  end
52
53
 
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
54
  # It is currently assumed that the common name is the first of the
67
55
  # `identifiers` passed into this method. If that is not the case, then
68
56
  # the `common_name` parameter needs to be set explicitly
69
- def certificate(identifiers:, algorithm: :ecdsa, common_name: Array(identifiers).first, **opts)
57
+ def managed_certificate(identifiers:, algorithm: :ecdsa, common_name: Array(identifiers).first, **opts)
70
58
  if (arr = denied_identifiers(identifiers)).size.positive?
71
59
  raise IdentifierNotAllowedError, "denied identifiers'#{arr.join(',')}'"
72
60
  end
@@ -74,7 +62,7 @@ module Anchor
74
62
  key_pem = cache&.read("#{common_name}+#{algorithm}")
75
63
  cert_pem = cache&.read(common_name)
76
64
 
77
- if key_pem.nil? || cert_pem.nil? || needs_renewal?(cert_pem: cert_pem)
65
+ if key_pem.nil? || cert_pem.nil?
78
66
  cert_pem, key_pem = provision(identifiers: identifiers, algorithm: algorithm, common_name: common_name,
79
67
  **opts)
80
68
 
@@ -82,15 +70,25 @@ module Anchor
82
70
  cache&.write(common_name, cert_pem)
83
71
  end
84
72
 
85
- [cert_pem, key_pem]
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)
86
85
  end
87
86
 
88
87
  def disable
89
88
  @enabled = false
90
89
  end
91
90
 
92
- # Currently this only tests for kid/hmac_key, but in the future it could
93
- # test for other configuration options.
91
+ # if the manager is enabled && the configuration is enabled
94
92
  def enabled?
95
93
  @enabled && configuration.enabled?
96
94
  end
@@ -128,19 +126,6 @@ module Anchor
128
126
  external_account_binding: external_account_binding)
129
127
  end
130
128
 
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
129
  def renew_after_from_seconds(cert:, before_seconds: renew_before_seconds)
145
130
  return nil unless before_seconds
146
131
 
@@ -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
@@ -13,8 +13,9 @@ end
13
13
  require_relative 'auto_cert/terms_of_service_acceptor'
14
14
  require_relative 'auto_cert/configuration'
15
15
  require_relative 'auto_cert/manager'
16
+ require_relative 'auto_cert/managed_certificate'
16
17
  require_relative 'auto_cert/identifier_policy'
17
18
  require_relative 'auto_cert/registry'
18
- require_relative 'auto_cert/integration'
19
+ require_relative 'auto_cert/renewal_busy_wait'
19
20
 
20
21
  require_relative 'auto_cert/railtie' if defined?(Rails::Railtie)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anchor
4
- VERSION = '0.3.0'
4
+ VERSION = '0.4.0'
5
5
  end
data/lib/puma/dsl.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ #
4
+ # Extend the ::Puma::DSL module with the configuration options we want for
5
+ # autocert
6
+ #
7
+
8
+ require 'puma/dsl'
9
+
10
+ module Puma
11
+ # Extend the ::Puma::DSL module with the configuration options we want
12
+ class DSL
13
+ def auto_cert_name(name = nil)
14
+ @options[:auto_cert_name] = name if name
15
+ @options[:auto_cert_name]
16
+ end
17
+
18
+ def auto_cert_port(port = nil)
19
+ @options[:auto_cert_port] = port if port
20
+ @options[:auto_cert_port]
21
+ end
22
+
23
+ def auto_cert_check_every(check_every = nil)
24
+ @options[:auto_cert_check_every] = check_every if check_every
25
+ @options[:auto_cert_check_every]
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../dsl'
4
+ module Puma
5
+ class Plugin
6
+ # This is a plugin for Puma that will automatically renew a certificate
7
+ #
8
+ # This module is here in order to communicate plugin configuration options
9
+ # to the plugin since the plugin is created dynamically and it is loaded and
10
+ # initialized without any configuration options.
11
+ module AutoCert
12
+ class << self
13
+ def ssl_bind_options(managed_certificate:)
14
+ {
15
+ cert: managed_certificate.cert_path,
16
+ key: managed_certificate.key_path
17
+ }
18
+ end
19
+ end
20
+
21
+ # Instance methods that are included in the dynamic Puma Plugin class when
22
+ # a plugin is created
23
+ module PluginInstanceMethods
24
+ attr_accessor :managed_certificate
25
+
26
+ def config(dsl)
27
+ port = dsl.auto_cert_port || ENV.fetch('HTTPS_PORT', nil)
28
+ name = dsl.auto_cert_name || ENV.fetch('AUTO_CERT_NAME', 'default')
29
+ configuration = ::Anchor::AutoCert::Registry.fetch(name)
30
+ identifiers = configuration.allow_identifiers
31
+ manager = ::Anchor::AutoCert::Manager.new(configuration: configuration)
32
+
33
+ @managed_certificate = manager.managed_certificate(identifiers: identifiers)
34
+
35
+ options = ::Puma::Plugin::AutoCert.ssl_bind_options(managed_certificate: @managed_certificate)
36
+
37
+ dsl.ssl_bind '[::]', port, options
38
+ rescue StandardError
39
+ @managed_certificate = nil
40
+ end
41
+
42
+ def start(launcher)
43
+ @launcher = launcher
44
+ unless managed_certificate&.enabled?
45
+ log_writer.log 'AutoCert >> Not enabled - skipping certificate renewal process'
46
+ return
47
+ end
48
+
49
+ log_writer.log "AutoCert >> Configured for #{managed_certificate.identifiers.join(', ')}"
50
+ check_every = launcher.config.options[:auto_cert_check_every] ||
51
+ ENV.fetch('AUTO_CERT_CHECK_EVERY', nil) ||
52
+ ::Anchor::AutoCert::RenewalBusyWait::ONE_HOUR
53
+
54
+ in_background do
55
+ Anchor::AutoCert::RenewalBusyWait.wait_for_it(managed_certificate: managed_certificate,
56
+ check_every: check_every) do
57
+ dump_cert_info
58
+
59
+ # if ssl server is up, then it has already read the local working
60
+ # files, which means we can purge them - if there's a disk cache, those still
61
+ # probably exist
62
+ ssl_server = launcher.binder.ios.find { |io| io.instance_of?(Puma::MiniSSL::Server) }
63
+ managed_certificate.purge_working_files if ssl_server
64
+
65
+ true
66
+ end
67
+ log_writer.log 'AutoCert >> Restarting Puma in order to renew certificate'
68
+ @launcher.restart
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def dump_cert_info
75
+ log_writer.debug "AutoCert >> Bound cert : #{managed_certificate.hex_serial}"
76
+ log_writer.debug "AutoCert >> common name : #{managed_certificate.common_name}"
77
+ log_writer.debug "AutoCert >> identifiers : #{managed_certificate.identifiers.join(', ')}"
78
+ log_writer.debug "AutoCert >> not before : #{managed_certificate.not_before}"
79
+ log_writer.debug "AutoCert >> not after : #{managed_certificate.not_after}"
80
+ end
81
+
82
+ def log_writer
83
+ if Gem::Version.new(Puma::Const::PUMA_VERSION) >= Gem::Version.new(6)
84
+ @launcher.log_writer
85
+ else
86
+ @launcher.events
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+
94
+ # This is the entry point for the plugin
95
+ Puma::Plugin.create do
96
+ include Puma::Plugin::AutoCert::PluginInstanceMethods
97
+ end
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.3.0
4
+ version: 0.4.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-06-05 00:00:00.000000000 Z
11
+ date: 2023-06-06 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: acme-client
@@ -142,8 +142,7 @@ files:
142
142
  - lib/anchor/auto_cert.rb
143
143
  - lib/anchor/auto_cert/configuration.rb
144
144
  - lib/anchor/auto_cert/identifier_policy.rb
145
- - lib/anchor/auto_cert/integration.rb
146
- - lib/anchor/auto_cert/integration/puma.rb
145
+ - lib/anchor/auto_cert/managed_certificate.rb
147
146
  - lib/anchor/auto_cert/manager.rb
148
147
  - lib/anchor/auto_cert/policy_check.rb
149
148
  - lib/anchor/auto_cert/policy_check/for_hostname.rb
@@ -151,8 +150,11 @@ files:
151
150
  - lib/anchor/auto_cert/policy_check/for_wildcard_hostname.rb
152
151
  - lib/anchor/auto_cert/railtie.rb
153
152
  - lib/anchor/auto_cert/registry.rb
153
+ - lib/anchor/auto_cert/renewal_busy_wait.rb
154
154
  - lib/anchor/auto_cert/terms_of_service_acceptor.rb
155
155
  - lib/anchor/version.rb
156
+ - lib/puma/dsl.rb
157
+ - lib/puma/plugin/auto_cert.rb
156
158
  homepage: https://anchor.dev
157
159
  licenses:
158
160
  - MIT
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Anchor
4
- module AutoCert
5
- module Integration
6
- # Puma integration
7
- class Puma
8
- def self.ssl_bind_options(manager:, identifiers: nil)
9
- identifiers ||= manager.configuration.allow_identifiers
10
- cert, key = manager.certificate_paths(identifiers: identifiers)
11
-
12
- { cert: cert, key: key }
13
- end
14
- end
15
- end
16
- end
17
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Anchor
4
- module AutoCert
5
- # Integration module
6
- module Integration
7
- end
8
- end
9
- end
10
-
11
- require_relative 'integration/puma'