anchor-pki 0.3.0 → 0.5.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: c07e54ede7ccff887e6023fe9842989720fdf9d63f159e4a5a3b4ba1a92c6c1a
4
+ data.tar.gz: dc0ba249f2125b9cc9d6a4a2dfda9fb042e86c0c7691d45fd6f7b432c5aa2d43
5
5
  SHA512:
6
- metadata.gz: 392139d7eea3518ba1d9f9915e628050818e3855494b55876e8fc63ffa4aff45e6434918f8fabae4bd6fe042210ac56fc7b1e0fe6093ee558013b85cb0d15551
7
- data.tar.gz: e8f674f66082121873997074914a619205b9375608a6db2f1bd4afe6199d43c96f9874d3d584811f251143cd3c559f90b30ec5ca70ef1c42f01c043da4c3d5ef
6
+ metadata.gz: e074e115e1035f8ed0b2289859073516b9cad04f9452f5dd8aa8dc070b8cdd119db2abf26b5bf2540a410818948fd4dfff4ca854cdc579b457bf15ae4065428a
7
+ data.tar.gz: 916f262c31580a3f92ca909bd0900d979a9ccb7a93d3f2cdb9e538bc9b29e8215db96468b5fe21c49c16ed836260857cc392f697772f032e6f76c28540af0653
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.
@@ -42,6 +44,27 @@ ACME_KID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
42
44
  ACME_HMAC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
43
45
  ```
44
46
 
47
+ ## Record new test cassettes
48
+
49
+ This code base tests against vcr recordings. These may need to be
50
+ regenerated periodically.
51
+
52
+ 1. check out the code base locally
53
+ 1. go to <https://anchor.dev/autocert-cab3bc/services/anchor-pki-rb-testing>
54
+ 1. in the **Server Setup** section, generate new `ACME_KID` & `ACME_HMAC_KEY`
55
+ tokens.
56
+ 1. Make a local `.env` file or similar containing:
57
+
58
+ export ACME_DIRECTORY_URL='https://anchor.dev/autocert-cab3bc/development/x509/ca/acme'
59
+ export ACME_KID=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
60
+ export ACME_HMAC_KEY=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
61
+
62
+ 1. on the command line execute:
63
+
64
+ $ . .env
65
+ $ rm -r spec/cassettes
66
+ $ bundle exec rake spec
67
+
45
68
  ## License
46
69
 
47
70
  The gem is available as open source under the terms of the [MIT
@@ -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,67 @@
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 expired?(now: Time.now.utc)
40
+ not_after <= now
41
+ end
42
+
43
+ def needs_renewal?(now = Time.now.utc)
44
+ manager.needs_renewal?(cert: x509, now: now)
45
+ end
46
+
47
+ def identifiers
48
+ alt_names = x509&.extensions&.find { |ext| ext.oid == 'subjectAltName' }&.value&.split(', ') || []
49
+ alt_names.map { |name| name.sub(/^DNS:/, '') }
50
+ end
51
+
52
+ def common_name
53
+ x509.subject.to_a.find { |name, _, _| name == 'CN' }[1]
54
+ end
55
+
56
+ def write_working_files
57
+ cert_path.open('w') { |f| f << cert_pem }
58
+ key_path.open('w') { |f| f << key_pem }
59
+ end
60
+
61
+ def purge_working_files
62
+ cert_path.delete if cert_path.exist?
63
+ key_path.delete if key_path.exist?
64
+ end
65
+ end
66
+ end
67
+ 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,23 +62,35 @@ 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)
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)
65
+ if !key_pem.nil? && !cert_pem.nil?
66
+ managed_cert = ManagedCertificate.new(manager: self, cert_pem: cert_pem, key_pem: key_pem)
67
+ return managed_cert unless managed_cert.expired?
83
68
  end
84
69
 
85
- [cert_pem, key_pem]
70
+ cert_pem, key_pem = provision(identifiers: identifiers, algorithm: algorithm, common_name: common_name, **opts)
71
+
72
+ cache&.write("#{common_name}+#{algorithm}", key_pem)
73
+ cache&.write(common_name, cert_pem)
74
+
75
+ ManagedCertificate.new(manager: self, cert_pem: cert_pem, key_pem: key_pem)
76
+ end
77
+
78
+ def needs_renewal?(cert:, now: Time.now.utc)
79
+ renew_after = [
80
+ renew_after_from_seconds(cert: cert),
81
+ renew_after_from_fraction(cert: cert),
82
+ renew_after_fallback(cert: cert),
83
+ cert.not_after # cert expired, get a new one
84
+ ].compact.min
85
+
86
+ (now > renew_after)
86
87
  end
87
88
 
88
89
  def disable
89
90
  @enabled = false
90
91
  end
91
92
 
92
- # Currently this only tests for kid/hmac_key, but in the future it could
93
- # test for other configuration options.
93
+ # if the manager is enabled && the configuration is enabled
94
94
  def enabled?
95
95
  @enabled && configuration.enabled?
96
96
  end
@@ -128,19 +128,6 @@ module Anchor
128
128
  external_account_binding: external_account_binding)
129
129
  end
130
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
131
  def renew_after_from_seconds(cert:, before_seconds: renew_before_seconds)
145
132
  return nil unless before_seconds
146
133
 
@@ -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)
data/lib/anchor/oid.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Anchor
4
+ # Oid is ASN.1 Object Identifiers.
5
+ module Oid
6
+ PEN = OpenSSL::ASN1::ObjectId.new('1.3.6.1.4.1.60900', OpenSSL::ASN1::OBJECT, :EXPLICIT, :PRIVATE)
7
+ CERT_EXT = OpenSSL::ASN1::ObjectId.new("#{PEN.oid}.1", OpenSSL::ASN1::OBJECT, :EXPLICIT, :PRIVATE)
8
+
9
+ OpenSSL::ASN1::ObjectId.register(CERT_EXT.oid, 'anchorCertExt', 'Anchor Certificate Extension')
10
+ end
11
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Anchor
4
- VERSION = '0.3.0'
4
+ VERSION = '0.5.0'
5
5
  end
data/lib/anchor-pki.rb CHANGED
@@ -5,5 +5,5 @@
5
5
  # this file is named anchor-pki.rb to match the gem name and to be consistent
6
6
  # with the other anchor modules for other languages
7
7
 
8
- require_relative './anchor'
8
+ require_relative 'anchor'
9
9
  # rubocop:enable Naming/FileName
data/lib/anchor.rb CHANGED
@@ -19,5 +19,6 @@ module Anchor
19
19
  end
20
20
  end
21
21
 
22
- require_relative './anchor/version'
23
- require_relative './anchor/auto_cert'
22
+ require_relative 'anchor/version'
23
+ require_relative 'anchor/auto_cert'
24
+ require_relative 'anchor/oid'
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,101 @@
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
+ attr_reader :port
26
+
27
+ def config(dsl)
28
+ @port = dsl.auto_cert_port || ENV.fetch('HTTPS_PORT', nil)
29
+ name = dsl.auto_cert_name || ENV.fetch('AUTO_CERT_NAME', 'default')
30
+ configuration = ::Anchor::AutoCert::Registry.fetch(name)
31
+ identifiers = configuration.allow_identifiers
32
+ manager = ::Anchor::AutoCert::Manager.new(configuration: configuration)
33
+
34
+ @managed_certificate = manager.managed_certificate(identifiers: identifiers)
35
+
36
+ options = ::Puma::Plugin::AutoCert.ssl_bind_options(managed_certificate: @managed_certificate)
37
+
38
+ dsl.ssl_bind '[::]', port, options
39
+ rescue StandardError
40
+ @managed_certificate = nil
41
+ end
42
+
43
+ def start(launcher)
44
+ @launcher = launcher
45
+ unless managed_certificate&.enabled?
46
+ log_writer.log 'AutoCert >> Not enabled - skipping certificate renewal process'
47
+ return
48
+ end
49
+
50
+ @managed_certificate.identifiers.each do |identifier|
51
+ log_writer.log "AutoCert >> Available at https://#{identifier}:#{port}/"
52
+ end
53
+
54
+ check_every = launcher.config.options[:auto_cert_check_every] ||
55
+ ENV.fetch('AUTO_CERT_CHECK_EVERY', nil) ||
56
+ ::Anchor::AutoCert::RenewalBusyWait::ONE_HOUR
57
+
58
+ in_background do
59
+ Anchor::AutoCert::RenewalBusyWait.wait_for_it(managed_certificate: managed_certificate,
60
+ check_every: check_every) do
61
+ dump_cert_info
62
+
63
+ # if ssl server is up, then it has already read the local working
64
+ # files, which means we can purge them - if there's a disk cache, those still
65
+ # probably exist
66
+ ssl_server = launcher.binder.ios.find { |io| io.instance_of?(Puma::MiniSSL::Server) }
67
+ managed_certificate.purge_working_files if ssl_server
68
+
69
+ true
70
+ end
71
+ log_writer.log 'AutoCert >> Restarting Puma in order to renew certificate'
72
+ @launcher.restart
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ def dump_cert_info
79
+ log_writer.debug "AutoCert >> Bound cert : #{managed_certificate.hex_serial}"
80
+ log_writer.debug "AutoCert >> common name : #{managed_certificate.common_name}"
81
+ log_writer.debug "AutoCert >> identifiers : #{managed_certificate.identifiers.join(', ')}"
82
+ log_writer.debug "AutoCert >> not before : #{managed_certificate.not_before}"
83
+ log_writer.debug "AutoCert >> not after : #{managed_certificate.not_after}"
84
+ end
85
+
86
+ def log_writer
87
+ if Gem::Version.new(Puma::Const::PUMA_VERSION) >= Gem::Version.new(6)
88
+ @launcher.log_writer
89
+ else
90
+ @launcher.events
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+
98
+ # This is the entry point for the plugin
99
+ Puma::Plugin.create do
100
+ include Puma::Plugin::AutoCert::PluginInstanceMethods
101
+ 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.5.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-09-18 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,12 @@ 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
+ - lib/anchor/oid.rb
155
156
  - lib/anchor/version.rb
157
+ - lib/puma/dsl.rb
158
+ - lib/puma/plugin/auto_cert.rb
156
159
  homepage: https://anchor.dev
157
160
  licenses:
158
161
  - 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'