anchor-pki 0.3.0 → 0.5.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: 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'