Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #23211 - Add PuppetCa TokenWhitelisting provider #592

Merged
merged 1 commit into from
Aug 27, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions bundler.d/puppetca_token_whitelisting.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
group :puppetca_token_whitelisting do
if RUBY_VERSION < '2.1'
gem 'jwt', '~> 1.5.6'
else
gem 'jwt'
end
end
1 change: 1 addition & 0 deletions config/settings.d/puppetca.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions config/settings.d/puppetca_token_whitelisting.yml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
#
# Configuration of the PuppetCA token_whitelisting provider
#

#:sign_all: false
#:token_ttl: 360
#:tokens_file: /var/lib/foreman-proxy/tokens.yml
# Which certificate to use when encrypting tokens (nil to use the SSL certificate)
#:certificate: nil
60 changes: 60 additions & 0 deletions extra/puppet_sign.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env ruby
#
# This takes a base64 encoded CSR as input and uploads it to foreman-proxy for analysis.
# It is used to verify if the CSR should be signed by PuppetCA.
#

require 'net/http'
require 'net/https'
require 'fileutils'
require 'json'
require 'yaml'
require 'socket'

def log(message)
puts message
`logger "[PuppetCA CSR Verify] #{message}"`
end

csr = STDIN.read

if RbConfig::CONFIG['host_os'] =~ /freebsd|dragonfly/i
settings_file = '/usr/local/etc/foreman-proxy/settings.yml'
else
settings_file = '/etc/foreman-proxy/settings.yml'
end
SETTINGS = YAML.load_file(settings_file)
PUPPETCA = YAML.load_file(SETTINGS[:settings_directory] + '/puppetca.yml')
unless PUPPETCA[:enabled]
log('PuppetCa is not enabled!')
exit 1
end
protocol = PUPPETCA[:enabled] == true ? 'https' : PUPPETCA[:enabled]
port = protocol == 'https' ? SETTINGS[:https_port] : SETTINGS[:http_port]
fqdn = Socket.gethostbyname(Socket.gethostname).first
# e.g. https://hostname.localdomain.com:8443/puppet/ca/autosign
uri = URI.parse("#{protocol}://#{fqdn}:#{port}/puppet/ca/autosign")
res = Net::HTTP.new(uri.host, uri.port)
if protocol == 'https'
res.use_ssl = true
res.ca_file = SETTINGS[:ssl_ca_file]
res.verify_mode = OpenSSL::SSL::VERIFY_PEER
res.cert = OpenSSL::X509::Certificate.new(File.read(SETTINGS[:ssl_certificate]))
res.key = OpenSSL::PKey::RSA.new(File.read(SETTINGS[:ssl_private_key]), nil)
end
res.open_timeout = SETTINGS[:timeout]
res.read_timeout = SETTINGS[:timeout]
req = Net::HTTP::Post.new(uri.request_uri)
req.add_field('Accept', 'application/json,version=2' )
req.body = csr
begin
res.start do |http|
response = http.request(req)
exit 0 if response.code == '200'
log("Called smart proxy for CSR verification, but received a #{response.code} http status.")
exit 1
end
rescue => e
log("Failed to call smart proxy for CSR verification. " + e.to_s)
exit 1
end
1 change: 1 addition & 0 deletions lib/smart_proxy_main.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
16 changes: 15 additions & 1 deletion modules/puppetca/puppetca_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,27 @@ class Api < ::Sinatra::Base
post "/autosign/:certname" do
content_type :json
certname = params[:certname]
token_ttl = params[:token_ttl]
begin
autosigner.autosign(certname)
autosigner.autosign(certname, token_ttl)
rescue => e
log_halt 406, "Failed to enable autosign for #{certname}: #{e}"
end
end

post "/validate" do
content_type :json
unless autosigner.respond_to?(:validate_csr)
log_halt 501, "Provider only supports trivial autosigning"
end
begin
request.body.rewind
autosigner.validate_csr(request.body.read) ? 200 : 404
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering about the 404 code; will there be a need to distinguish between a proxy without this feature and one with? Probably not but it might be worth to consider 400 Bad Request for invalid CSRs.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well the 404 here only gets returned when the CSR is valid but the token is not. A proxy without this feature returns 501 (line 43) and an invalid CSR raises an Exception leading to a 406 (line 49). Does that work for you?

rescue => e
log_halt 406, "Failed to validate CSR: #{e}"
end
end

delete "/autosign/:certname" do
content_type :json
certname = params[:certname]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def disable certname

# add certname to puppet autosign file
# parameter is certname to use
def autosign certname
def autosign certname, ttl
FileUtils.touch(autosign_file) unless File.exist?(autosign_file)

open(autosign_file, File::RDWR) do |autosign|
Expand Down
14 changes: 14 additions & 0 deletions modules/puppetca_token_whitelisting/plugin_configuration.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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'
require 'puppetca_token_whitelisting/puppetca_token_whitelisting_token_storage'
end

def load_dependency_injection_wirings(container_instance, settings)
container_instance.dependency :autosigner, lambda { ::Proxy::PuppetCa::TokenWhitelisting::Autosigner.new }
end
end
end

Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require 'puppetca_token_whitelisting/plugin_configuration'
require 'puppetca_token_whitelisting/puppetca_token_whitelisting_plugin'
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
require 'jwt'
require 'openssl'

module ::Proxy::PuppetCa::TokenWhitelisting
class Autosigner
include ::Proxy::Log
include ::Proxy::Util

JWT_ALGORITHM = 'RS512'
RSA_BITSIZE = '2048'

def tokens_file
Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.tokens_file
end

def sign_all
Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.sign_all
end

def smartproxy_cert
@certificate ||= OpenSSL::PKey::RSA.new File.read cert_file
end

def storage
Proxy::PuppetCa::TokenWhitelisting::TokenStorage.new tokens_file
end

def token_ttl
Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.token_ttl
end

def cert_file
return Proxy::SETTINGS.ssl_private_key.to_s if Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.certificate.nil?

file = Proxy::PuppetCa::TokenWhitelisting::Plugin.settings.certificate
unless File.exist?(file)
File.write file, OpenSSL::PKey::RSA.generate(RSA_BITSIZE)
File.chmod 0600, file
end
file
end

# Invalidate a token based on the certname
def disable certname
storage.remove_if do |token|
begin
decoded = JWT.decode(token, smartproxy_cert.public_key, true, algorithm: JWT_ALGORITHM)
decoded.first['certname'] == certname
rescue JWT::ExpiredSignature
true
end
end
end

# Create a new token for a certname
def autosign certname, ttl
ttl = ttl.to_i > 0 ? ttl.to_i : token_ttl
payload = { certname: certname, exp: Time.now.to_i + ttl * 60 }
token = JWT.encode payload, smartproxy_cert, JWT_ALGORITHM
storage.add token
{ generated_token: token }.to_json
end

# List the hosts that are currently valid
def autosign_list
storage.read.collect do |token|
begin
decoded = JWT.decode(token, smartproxy_cert.public_key, true, algorithm: JWT_ALGORITHM)
decoded.first['certname']
rescue JWT::ExpiredSignature
nil
end
end.compact
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."
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be info I think.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We had that when we talked about the first SmartProxyPR #576 where witlessbird suggested warnings.

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
JWT.decode(token, smartproxy_cert.public_key, true, algorithm: JWT_ALGORITHM)
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 storage.read.include? token
logger.warn "Certname not valid."
return false
end
storage.remove token
return true
end
end
end
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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, :tokens_file => '/var/lib/foreman-proxy/tokens.yml', :token_ttl => 360, :certificate => nil

load_classes ::Proxy::PuppetCa::TokenWhitelisting::PluginConfiguration
load_dependency_injection_wirings ::Proxy::PuppetCa::TokenWhitelisting::PluginConfiguration
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
module ::Proxy::PuppetCa::TokenWhitelisting
class TokenStorage
include ::Proxy::Log
include ::Proxy::Util

def initialize tokens_file
@tokens_file = tokens_file
ensure_file
end

def ensure_file
return if File.exist?(@tokens_file)
FileUtils.mkdir_p File.dirname(@tokens_file)
FileUtils.touch @tokens_file
write []
end

def read
if RUBY_VERSION < '2.1'
YAML.load_file @tokens_file
else
YAML.safe_load File.read @tokens_file
end
end

def write content
lock do
unsafe_write content
end
end

def unsafe_write content
File.write @tokens_file, content.to_yaml
end

def lock &block
File.open(@tokens_file, "r+") do |f|
begin
f.flock File::LOCK_EX
yield
ensure
f.flock File::LOCK_UN
end
end
end

def add entry
write read.push entry
end

def remove entry
write read.delete_if { |data| data == entry }
end

def remove_if &block
lock do
unsafe_write read.delete_if { |token| yield(token) }
end
end
end
end
Loading