From 5bf0c2b4dc901824e50a834d92a647ed3fdc91bb Mon Sep 17 00:00:00 2001 From: Julian Todt Date: Thu, 7 Jun 2018 12:55:35 +0200 Subject: [PATCH] Fixes #23211 - Add PuppetCa TokenVerify provider --- bundler.d/puppetca_token_whitelisting.rb | 3 + config/settings.d/puppetca.yml.example | 1 + .../puppetca_token_whitelisting.yml.example | 7 ++ lib/smart_proxy_main.rb | 1 + modules/puppetca/puppetca_api.rb | 13 +++ .../plugin_configuration.rb | 13 +++ .../puppetca_token_whitelisting.rb | 2 + .../puppetca_token_whitelisting_autosigner.rb | 96 +++++++++++++++++++ .../puppetca_token_whitelisting_csr.rb | 25 +++++ .../puppetca_token_whitelisting_plugin.rb | 11 +++ test/fixtures/puppetca/csr_example.pem | 26 +++++ test/fixtures/puppetca/hosts.yml | 3 + ...etca_token_whitelisting_autosigner_test.rb | 96 +++++++++++++++++++ ...puppetca_token_whitelisting_config_test.rb | 12 +++ .../puppetca_token_whitelisting_csr_test.rb | 23 +++++ 15 files changed, 332 insertions(+) create mode 100644 bundler.d/puppetca_token_whitelisting.rb create mode 100644 config/settings.d/puppetca_token_whitelisting.yml.example create mode 100644 modules/puppetca_token_whitelisting/plugin_configuration.rb create mode 100644 modules/puppetca_token_whitelisting/puppetca_token_whitelisting.rb create mode 100644 modules/puppetca_token_whitelisting/puppetca_token_whitelisting_autosigner.rb create mode 100644 modules/puppetca_token_whitelisting/puppetca_token_whitelisting_csr.rb create mode 100644 modules/puppetca_token_whitelisting/puppetca_token_whitelisting_plugin.rb create mode 100644 test/fixtures/puppetca/csr_example.pem create mode 100644 test/fixtures/puppetca/hosts.yml create mode 100644 test/puppetca_token_whitelisting/puppetca_token_whitelisting_autosigner_test.rb create mode 100644 test/puppetca_token_whitelisting/puppetca_token_whitelisting_config_test.rb create mode 100644 test/puppetca_token_whitelisting/puppetca_token_whitelisting_csr_test.rb diff --git a/bundler.d/puppetca_token_whitelisting.rb b/bundler.d/puppetca_token_whitelisting.rb new file mode 100644 index 0000000000..3e6cba887c --- /dev/null +++ b/bundler.d/puppetca_token_whitelisting.rb @@ -0,0 +1,3 @@ +group :puppetca_token_whitelisting do + gem 'jwt' +end diff --git a/config/settings.d/puppetca.yml.example b/config/settings.d/puppetca.yml.example index 965cf06554..0d2ebf348e 100644 --- a/config/settings.d/puppetca.yml.example +++ b/config/settings.d/puppetca.yml.example @@ -4,6 +4,7 @@ # valid providers: # - puppetca_hostname_whitelisting (verify CSRs based on a hostname whitelist) +# - puppetca_token_whitelisting (verify CSRs based on a token whitelist) #:use_provider: puppetca_hostname_whitelisting #:ssldir: /var/lib/puppet/ssl #:puppetca_use_sudo: true diff --git a/config/settings.d/puppetca_token_whitelisting.yml.example b/config/settings.d/puppetca_token_whitelisting.yml.example new file mode 100644 index 0000000000..a673e50a60 --- /dev/null +++ b/config/settings.d/puppetca_token_whitelisting.yml.example @@ -0,0 +1,7 @@ +--- +# +# Configuration of the PuppetCA token_whitelisting provider +# + +#:sign_all: false +#:hosts_file: /tmp/foreman-proxy/hosts.yml diff --git a/lib/smart_proxy_main.rb b/lib/smart_proxy_main.rb index 61a7985213..65fe1b2b0f 100644 --- a/lib/smart_proxy_main.rb +++ b/lib/smart_proxy_main.rb @@ -68,6 +68,7 @@ module Proxy require 'dhcp_libvirt/dhcp_libvirt' require 'puppetca/puppetca' require 'puppetca_hostname_whitelisting/puppetca_hostname_whitelisting' + require 'puppetca_token_whitelisting/puppetca_token_whitelisting' require 'puppet_proxy/puppet' require 'puppet_proxy_customrun/puppet_proxy_customrun' require 'puppet_proxy_legacy/puppet_proxy_legacy' diff --git a/modules/puppetca/puppetca_api.rb b/modules/puppetca/puppetca_api.rb index a7e1cf0c1e..9d8739b8c5 100644 --- a/modules/puppetca/puppetca_api.rb +++ b/modules/puppetca/puppetca_api.rb @@ -36,6 +36,19 @@ class Api < ::Sinatra::Base end end + post "/validate" do + content_type :json + unless autosigner.respond_to?('validate_csr') + log_halt 401, "Provider only supports trivial autosigning" + end + begin + request.body.rewind + autosigner.validate_csr(request.body.read) ? 200 : 404 + rescue => e + log_halt 406, "Failed to validate CSR: #{e}" + end + end + delete "/autosign/:certname" do content_type :json certname = params[:certname] diff --git a/modules/puppetca_token_whitelisting/plugin_configuration.rb b/modules/puppetca_token_whitelisting/plugin_configuration.rb new file mode 100644 index 0000000000..f9954aa733 --- /dev/null +++ b/modules/puppetca_token_whitelisting/plugin_configuration.rb @@ -0,0 +1,13 @@ +module ::Proxy::PuppetCa::TokenWhitelisting + class PluginConfiguration + def load_classes + require 'puppetca_token_whitelisting/puppetca_token_whitelisting_autosigner' + require 'puppetca_token_whitelisting/puppetca_token_whitelisting_csr' + end + + def load_dependency_injection_wirings(container_instance, settings) + container_instance.dependency :autosigner, lambda { ::Proxy::PuppetCa::TokenWhitelisting::Autosigner.new } + end + end +end + diff --git a/modules/puppetca_token_whitelisting/puppetca_token_whitelisting.rb b/modules/puppetca_token_whitelisting/puppetca_token_whitelisting.rb new file mode 100644 index 0000000000..aef5ee99cb --- /dev/null +++ b/modules/puppetca_token_whitelisting/puppetca_token_whitelisting.rb @@ -0,0 +1,2 @@ +require 'puppetca_token_whitelisting/plugin_configuration' +require 'puppetca_token_whitelisting/puppetca_token_whitelisting_plugin' diff --git a/modules/puppetca_token_whitelisting/puppetca_token_whitelisting_autosigner.rb b/modules/puppetca_token_whitelisting/puppetca_token_whitelisting_autosigner.rb new file mode 100644 index 0000000000..2a37e41edc --- /dev/null +++ b/modules/puppetca_token_whitelisting/puppetca_token_whitelisting_autosigner.rb @@ -0,0 +1,96 @@ +require 'jwt' +require 'openssl' + +module ::Proxy::PuppetCa::TokenWhitelisting + class Autosigner + include ::Proxy::Log + include ::Proxy::Util + + def sign_all + Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.sign_all + end + + def hosts_file + Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.hosts_file + end + + def smartproxy_cert + OpenSSL::PKey::RSA.new File.read Proxy::SETTINGS.ssl_private_key.to_s + end + + def ensure_hostsfile + unless File.exist?(hosts_file) + FileUtils.mkdir_p hosts_file.rpartition('/').first + FileUtils.touch hosts_file + File.write(hosts_file, [].to_yaml) + end + end + + # Invalidate a token based on the certname + def disable certname + hosts = autosign_list + hosts.delete(certname) + File.write hosts_file, hosts.to_yaml + end + + # Create a new token for a certname + def autosign certname + ensure_hostsfile + payload = { certname: certname, exp: Time.now.to_i + 10 * 60 * 60 } + token = JWT.encode payload, smartproxy_cert, 'RS512' + File.write hosts_file, autosign_list.push(certname).to_yaml + { generated_token: token }.to_json + end + + # List the hosts that are currently valid + def autosign_list + ensure_hostsfile + YAML.load_file(hosts_file).to_a + end + + # Check whether a csr is valid and should be signed + # by checking its token if it exists + def validate_csr csr + if csr.nil? + logger.warn "Request did not include a CSR." + return false + end + if sign_all + logger.warn "Signing CSR without token verification." + return true + end + begin + req = Proxy::PuppetCa::TokenWhitelisting::CSR.new csr + token = req.challenge_password + rescue + logger.warn "Invalid CSR" + return false + end + if token.nil? + logger.warn "CSR did not include a token." + return false + end + validate_token token + end + + def validate_token token + # token didnt expire? + begin + decoded = JWT.decode(token, smartproxy_cert.public_key, true, algorithm: 'RS512') + rescue JWT::ExpiredSignature + logger.warn "Token already expired." + return false + rescue JWT::DecodeError + logger.warn "Failed to decode token." + return false + end + # token in our list? + unless autosign_list.include? decoded.first['certname'] + logger.warn "Certname not valid." + return false + end + disable decoded.first['certname'] + return true + end + end +end diff --git a/modules/puppetca_token_whitelisting/puppetca_token_whitelisting_csr.rb b/modules/puppetca_token_whitelisting/puppetca_token_whitelisting_csr.rb new file mode 100644 index 0000000000..3da76d7cd4 --- /dev/null +++ b/modules/puppetca_token_whitelisting/puppetca_token_whitelisting_csr.rb @@ -0,0 +1,25 @@ +module Proxy::PuppetCa::TokenWhitelisting + class CSR + attr_reader :csr + + def initialize(raw_csr) + @csr = OpenSSL::X509::Request.new(raw_csr) + end + + def challenge_password + attribute = custom_attributes.detect do |attr| + ['challengePassword', '1.2.840.113549.1.9.7'].include?(attr[:oid]) + end + attribute ? attribute[:value] : nil + end + + def custom_attributes + @csr.attributes.map do |attr| + { + oid: attr.oid, + value: attr.value.value.first.value + } + end + end + end +end diff --git a/modules/puppetca_token_whitelisting/puppetca_token_whitelisting_plugin.rb b/modules/puppetca_token_whitelisting/puppetca_token_whitelisting_plugin.rb new file mode 100644 index 0000000000..43bc748c9e --- /dev/null +++ b/modules/puppetca_token_whitelisting/puppetca_token_whitelisting_plugin.rb @@ -0,0 +1,11 @@ +module ::Proxy::PuppetCa::TokenWhitelisting + class Plugin < ::Proxy::Provider + plugin :puppetca_token_whitelisting, ::Proxy::VERSION + + requires :puppetca, ::Proxy::VERSION + default_settings :sign_all => false, :hosts_file => '/tmp/foreman-proxy/hosts.yml' + + load_classes ::Proxy::PuppetCa::TokenWhitelisting::PluginConfiguration + load_dependency_injection_wirings ::Proxy::PuppetCa::TokenWhitelisting::PluginConfiguration + end +end diff --git a/test/fixtures/puppetca/csr_example.pem b/test/fixtures/puppetca/csr_example.pem new file mode 100644 index 0000000000..89eeeabfb5 --- /dev/null +++ b/test/fixtures/puppetca/csr_example.pem @@ -0,0 +1,26 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIEdTCCAl0CAQAwGzEZMBcGA1UEAwwQdGVzdC5leGFtcGxlLmNvbTCCAiIwDQYJ +KoZIhvcNAQEBBQADggIPADCCAgoCggIBAK+eSI5LYrBUROs02A9DYPqEpnN1YokD +MSb47NQW0A4+o7B7h/B3HFN2Moo/US/zNLDvHGuVDZIaIpudtNBTBSX0MQPtF8tQ +Lm7WA6wGwqpX3eR3OLXdWR/T4wIvFQ1gvCS/snuW8YennSeAl1Yijtr2EHiPFJ/i +Dy67vGhvXrqVDl/svIf13uw9zcxZ34VfE8zYg5WZ/thlhjbue/KKJdn+riNIYNK4 +gwEjFNER9U38UPZUXMNJCEAXEJ7GKXQUKMXXtybp5jihPxTjbxFKilaIEJNM35ej +9Ra0OREGc0Cc0beaW9+n9ZFBxsfM/NV0nzOos4tmSEyAq54Nbd80ropYivrmSRQf +/yDPijvmC4frL1nhSJaf17rmfw27urbPcObbpuQFXCIlNFJd/CwpPds6ikZ9otDx +yTzPveLbQUCh3CzReODC7jopi7vchPH3cZVvuaE90REKL++3xHqOzRpq5tZ1xc2j +3agiQmTthnzABx6cj/q2ab4YAwpaSIAZFoHtzD8tsyD1WTGkL9jzBkCULd7ZcQdR +AL0PwGLYI2hbCsQV6i9noVZZ19+hHEjjk06lG5SKK+H8eTIybWr9IPU/yZowNPtd +et6samT8ltBLAxqTFffrdsTUTQAoN+ykgsML/N3LUuPACg1G6ge0MQVVvBIfCNiP +SjfPJmthHHc1AgMBAAGgFTATBgkqhkiG9w0BCQcxBhMEMTIzNDANBgkqhkiG9w0B +AQsFAAOCAgEAfTtWZAP8z9pxR6esLkoCfhhXaYzjnzJ5/4r+x/VPpJQEzI37CScG +Dma+UzVkIddCVc5oFtzLtVZtGTaygW2QyR7wu+an0qQBs+MVzkjPPnMLerDUR89c +Nk5DlDMaKFYV3JJpg2G2YdmOR0SEF0640Aw0/Ftx41iTSLSFopDMcTPRbZ9zm2AN +uer2TMIWpio6k6OyEJHkyifQOG1kgI+amVgk21kVRm5qUZV01QduLSxuN8KQKDYT +dmE31BpSQdVYzvrRPV558+NiWSrRheQtLfCl4BUZsZjfgh7OXSiy/yCZ6Co7FeDX +WbVtlIeaFukt5fPD4VKBQVIP296ZiB4BFIDLUcq87DaWbjbO1owS/B89MbAM6Fjy +FHCE3x0nUev3LYrKrJGouHYXiyUNGQk1ilI60y1xc3+B3ErjzpszJB1uBRk1fsem +Np60VUVqNPD7GzxlE/acgEa2Xij1vl+g4yIjWGVv5Fokb4fO6K+n8iSEYKG4+nMr ++vjP7bTcW4GymYPH38TtQfzhXvFMODL8ehiy2xjndEfLMitrbP1vjiLrCZD6gIk3 +alxj7nHJMXF83Rqg/7OhERMVmUGE+tDiRD95bMK/eYq4sj49zke6puf5r0MeW6Fv +jfuZ0r0kJmJF/r2FZEKuScl0uS4/RWUvgUdUFwpZ3i8KzJWJ6NDm7eY= +-----END CERTIFICATE REQUEST----- diff --git a/test/fixtures/puppetca/hosts.yml b/test/fixtures/puppetca/hosts.yml new file mode 100644 index 0000000000..40beb2a07d --- /dev/null +++ b/test/fixtures/puppetca/hosts.yml @@ -0,0 +1,3 @@ +--- +- foo.example.com +- test.bar.example.com diff --git a/test/puppetca_token_whitelisting/puppetca_token_whitelisting_autosigner_test.rb b/test/puppetca_token_whitelisting/puppetca_token_whitelisting_autosigner_test.rb new file mode 100644 index 0000000000..5a8aa63aae --- /dev/null +++ b/test/puppetca_token_whitelisting/puppetca_token_whitelisting_autosigner_test.rb @@ -0,0 +1,96 @@ +require 'test_helper' +require 'tempfile' +require 'fileutils' +require 'openssl' +require 'yaml' +require 'json' + +require 'puppetca/puppetca' +require 'puppetca_token_whitelisting/puppetca_token_whitelisting' +require 'puppetca_token_whitelisting/puppetca_token_whitelisting_autosigner' +require 'puppetca_token_whitelisting/puppetca_token_whitelisting_csr' + +class PuppetCaTokenWhitelistingAutosignerTest < Test::Unit::TestCase + def setup + @file = Tempfile.new('autosign_test') + begin + ## Setup + FileUtils.cp './test/fixtures/puppetca/hosts.yml', @file.path + rescue + @file.close + @file.unlink + @file = nil + end + @autosigner = Proxy::PuppetCa::TokenWhitelisting::Autosigner.new + @autosigner.stubs(:hosts_file).returns(@file.path) + rsa_cert = OpenSSL::PKey::RSA.generate 2048 + @autosigner.stubs(:smartproxy_cert).returns(rsa_cert) + end + + def teardown + @file.close + @file.unlink + end + + def test_should_list_autosign_entries + assert_equal @autosigner.autosign_list, ['foo.example.com', 'test.bar.example.com'] + end + + def test_should_add_autosign_entry + @autosigner.autosign 'foobar.example.com' + assert_equal @autosigner.autosign_list, ['foo.example.com', 'test.bar.example.com', 'foobar.example.com'] + end + + def test_should_create_correct_token + response = @autosigner.autosign 'baz.example.com' + token = JSON.parse(response)['generated_token'] + decoded = JWT.decode(token, @autosigner.smartproxy_cert.public_key, true, algorithm: 'RS512') + assert_equal decoded.first['certname'], 'baz.example.com' + assert 100 > (decoded.first['exp'] - Time.now.to_i - 10 * 60 * 60).abs + end + + def test_should_remove_autosign_entry + @autosigner.disable 'foo.example.com' + assert_equal @autosigner.autosign_list, ['test.bar.example.com'] + end + + def test_should_validate_on_sign_all + @autosigner.stubs(:sign_all).returns(true) + assert_true @autosigner.validate_csr '' + end + + def test_should_call_verification + csr_example = File.read './test/fixtures/puppetca/csr_example.pem' + @autosigner.expects(:validate_token).with('1234').returns(true) + assert_true @autosigner.validate_csr csr_example + end + + def test_should_validate_a_correct_token + response = @autosigner.autosign 'signme.example.com' + token = JSON.parse(response)['generated_token'] + + assert_true @autosigner.validate_token token + end + + def test_should_not_validate_expired_token + payload = { certname: 'foo.example.com', exp: Time.now.to_i - 10 } + token = JWT.encode payload, @autosigner.smartproxy_cert, 'RS512' + + assert_false @autosigner.validate_token token + end + + def test_should_not_validate_token_with_invalid_certname + payload = { certname: 'unknown.example.com', exp: Time.now.to_i + 999_999 } + token = JWT.encode payload, @autosigner.smartproxy_cert, 'RS512' + + assert_false @autosigner.validate_token token + end + + def test_should_not_validate_token_with_unkown_signature + unknown_cert = OpenSSL::PKey::RSA.generate 2048 + payload = { certname: 'foo.example.com', exp: Time.now.to_i + 999_999 } + token = JWT.encode payload, unknown_cert, 'RS512' + + assert_false @autosigner.validate_token token + end +end diff --git a/test/puppetca_token_whitelisting/puppetca_token_whitelisting_config_test.rb b/test/puppetca_token_whitelisting/puppetca_token_whitelisting_config_test.rb new file mode 100644 index 0000000000..fdadb8e7fe --- /dev/null +++ b/test/puppetca_token_whitelisting/puppetca_token_whitelisting_config_test.rb @@ -0,0 +1,12 @@ +require 'test_helper' +require 'puppetca/puppetca' +require 'puppetca_token_whitelisting/puppetca_token_whitelisting' +require 'puppetca_token_whitelisting/puppetca_token_whitelisting_plugin' + +class PuppetCATokenWhitelistingConfigTest < Test::Unit::TestCase + def test_omitted_settings_have_default_values + Proxy::PuppetCa::TokenWhitelisting::Plugin.load_test_settings({}) + assert_equal false, Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.sign_all + assert_equal '/tmp/foreman-proxy/hosts.yml', Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.hosts_file + end +end diff --git a/test/puppetca_token_whitelisting/puppetca_token_whitelisting_csr_test.rb b/test/puppetca_token_whitelisting/puppetca_token_whitelisting_csr_test.rb new file mode 100644 index 0000000000..7f32f4547e --- /dev/null +++ b/test/puppetca_token_whitelisting/puppetca_token_whitelisting_csr_test.rb @@ -0,0 +1,23 @@ +require 'test_helper' + +require 'puppetca/puppetca' +require 'puppetca_token_whitelisting/puppetca_token_whitelisting' +require 'puppetca_token_whitelisting/puppetca_token_whitelisting_csr' + +class PuppetCaTokenWhitelistingCSRTest < Test::Unit::TestCase + def setup + @csr_example = File.read './test/fixtures/puppetca/csr_example.pem' + end + + def test_should_extract_correct_attribute + req = Proxy::PuppetCa::TokenWhitelisting::CSR.new @csr_example + assert_equal '1234', req.challenge_password + end + + def test_should_fail_on_invalid_csr + @csr_example.slice!(42...69) + assert_raise OpenSSL::X509::RequestError do + Proxy::PuppetCa::TokenWhitelisting::CSR.new @csr_example + end + end +end