anchor-pki 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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'